import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { detectColorScheme, parseRkeyFromUri, resolveUserThemePreference, FALLBACK_THEME, fallbackForScheme, resolveTheme, } from "../theme-resolution.js"; import { ThemeCache } from "../theme-cache.js"; import { logger } from "../logger.js"; vi.mock("../logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, })); describe("detectColorScheme", () => { it("returns 'light' by default when no cookie or hint", () => { expect(detectColorScheme(undefined, undefined)).toBe("light"); }); it("reads atbb-color-scheme=dark from cookie", () => { expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); }); it("reads atbb-color-scheme=light from cookie", () => { expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); }); it("prefers cookie over client hint", () => { expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); }); it("falls back to client hint when no cookie", () => { expect(detectColorScheme(undefined, "dark")).toBe("dark"); }); it("ignores unrecognized hint values and returns 'light'", () => { expect(detectColorScheme(undefined, "no-preference")).toBe("light"); }); it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => { // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched. // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted. expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light"); }); }); describe("parseRkeyFromUri", () => { it("extracts rkey from valid AT URI", () => { expect( parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") ).toBe("3lblthemeabc"); }); it("returns null for URI with no rkey segment", () => { expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); }); it("returns null for malformed URI", () => { expect(parseRkeyFromUri("not-a-uri")).toBeNull(); }); it("returns null for empty string", () => { expect(parseRkeyFromUri("")).toBeNull(); }); }); describe("resolveUserThemePreference", () => { const availableThemes = [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, ]; it("returns null when allowUserChoice is false", () => { const result = resolveUserThemePreference( "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light", availableThemes, false ); expect(result).toBeNull(); }); it("returns atbb-light-theme URI when cookie matches and is in availableThemes", () => { const result = resolveUserThemePreference( "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light", availableThemes, true ); expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbllight"); }); it("returns atbb-dark-theme URI when cookie matches and is in availableThemes", () => { const result = resolveUserThemePreference( "atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark", "dark", availableThemes, true ); expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbldark"); }); it("returns null when cookie URI is not in availableThemes (stale/removed)", () => { const result = resolveUserThemePreference( "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale", "light", availableThemes, true ); expect(result).toBeNull(); }); it("returns null when cookieHeader is undefined", () => { const result = resolveUserThemePreference( undefined, "light", availableThemes, true ); expect(result).toBeNull(); }); it("returns null when cookie value is empty string after cookie name", () => { const result = resolveUserThemePreference( "atbb-light-theme=", "light", availableThemes, true ); expect(result).toBeNull(); }); it("does not match x-atbb-light-theme as a cookie prefix", () => { const result = resolveUserThemePreference( "x-atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light", availableThemes, true ); expect(result).toBeNull(); }); }); describe("FALLBACK_THEME", () => { it("uses neobrutal-light tokens", () => { expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); }); it("has light colorScheme", () => { expect(FALLBACK_THEME.colorScheme).toBe("light"); }); it("includes Google Fonts URL for Space Grotesk", () => { expect(FALLBACK_THEME.fontUrls).toEqual( expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) ); }); it("has null cssOverrides", () => { expect(FALLBACK_THEME.cssOverrides).toBeNull(); }); }); describe("fallbackForScheme", () => { it("returns light tokens for light color scheme", () => { const result = fallbackForScheme("light"); expect(result.tokens["color-bg"]).toBe("#f5f0e8"); expect(result.colorScheme).toBe("light"); }); it("returns dark tokens for dark color scheme", () => { const result = fallbackForScheme("dark"); expect(result.tokens["color-bg"]).toBe("#1a1a1a"); expect(result.colorScheme).toBe("dark"); }); }); describe("resolveTheme", () => { const mockFetch = vi.fn(); const mockLogger = vi.mocked(logger); const APPVIEW = "http://localhost:3001"; beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockLogger.warn.mockClear(); mockLogger.error.mockClear(); }); afterEach(() => { mockFetch.mockReset(); vi.unstubAllGlobals(); }); function policyResponse(overrides: object = {}) { return { ok: true, json: () => Promise.resolve({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], ...overrides, }), }; } function themeResponse(colorScheme: "light" | "dark", cid: string) { return { ok: true, json: () => Promise.resolve({ cid, tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, cssOverrides: null, fontUrls: null, colorScheme, }), }; } it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(result.colorScheme).toBe("light"); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.objectContaining({ operation: "resolveTheme", status: 404 }) ); }); it("returns dark fallback tokens when policy fails and dark cookie set", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); expect(result.tokens).toEqual(fallbackForScheme("dark").tokens); expect(result.colorScheme).toBe("dark"); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.any(Object) ); }); it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null })); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); }); it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => { mockFetch.mockResolvedValueOnce( policyResponse({ defaultLightThemeUri: "malformed-uri" }) ); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); // Only one fetch should happen (policy only — no theme fetch) expect(mockFetch).toHaveBeenCalledTimes(1); }); it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: false, status: 404 }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.objectContaining({ operation: "resolveTheme", status: 404 }) ); }); it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("CID mismatch"), expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) ); }); it("resolves the light theme on happy path (no cookie)", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens["color-bg"]).toBe("#fff"); expect(result.colorScheme).toBe("light"); expect(result.cssOverrides).toBeNull(); expect(result.fontUrls).toBeNull(); }); it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); expect(result.tokens["color-bg"]).toBe("#111"); expect(result.colorScheme).toBe("dark"); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); }); it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const result = await resolveTheme(APPVIEW, undefined, "dark"); expect(result.colorScheme).toBe("dark"); }); it("returns FALLBACK_THEME and logs error on network exception", async () => { mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("Theme policy fetch failed"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("returns dark fallback tokens when network exception occurs with dark cookie", async () => { // Regression: fallbackForScheme() must return dark tokens when the detected scheme is dark. // Previously, all fallback paths returned FALLBACK_THEME (light tokens) regardless of scheme. mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); expect(result.tokens).toEqual(fallbackForScheme("dark").tokens); expect(result.colorScheme).toBe("dark"); expect(result.tokens).not.toEqual(FALLBACK_THEME.tokens); }); it("re-throws programming errors (TypeError) rather than swallowing them", async () => { // A TypeError from a bug in the code should propagate, not be silently logged. // This TypeError comes from the fetch() mock itself (not from .json()), so it // is caught by the policy-fetch try block and re-thrown as a programming error. mockFetch.mockImplementationOnce(() => { throw new TypeError("Cannot read properties of null"); }); await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError); }); it("passes cssOverrides and fontUrls through from theme response", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: ".btn { font-weight: 700; }", fontUrls: ["https://fonts.example.com/font.css"], colorScheme: "light", }), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); }); it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("invalid JSON"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("invalid JSON"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => { mockFetch .mockResolvedValueOnce(policyResponse({ availableThemes: [] })) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("not in availableThemes"), expect.objectContaining({ operation: "resolveTheme", themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", }) ); }); it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".." // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch mockFetch.mockResolvedValueOnce( policyResponse({ defaultLightThemeUri: "at://did/col/../../secret", }) ); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); // Only the policy fetch should have been made (no theme fetch) expect(mockFetch).toHaveBeenCalledTimes(1); }); it("no cache provided — behaves identically to pre-cache implementation", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const result = await resolveTheme(APPVIEW, undefined, undefined, undefined); expect(result.tokens["color-bg"]).toBe("#fff"); expect(mockFetch).toHaveBeenCalledTimes(2); }); it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { // Live refs have no CID — canonical atbb.space presets ship this way. // The CID integrity check must be skipped when expectedCid is null. mockFetch .mockResolvedValueOnce( policyResponse({ availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid ], }) ) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const result = await resolveTheme(APPVIEW, undefined, undefined); // Theme resolved successfully — live ref does not trigger CID mismatch expect(result.tokens["color-bg"]).toBe("#fff"); expect(result.colorScheme).toBe("light"); expect(mockLogger.warn).not.toHaveBeenCalledWith( expect.stringContaining("CID mismatch"), expect.any(Object) ); }); it("resolves light preference cookie when URI is in availableThemes", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const cookieHeader = "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight"; const result = await resolveTheme(APPVIEW, cookieHeader, undefined); expect(result.tokens["color-bg"]).toBe("#fff"); expect(result.colorScheme).toBe("light"); // Verify that the user's theme was fetched (rkey 3lbllight) not the forum default expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); }); it("resolves dark preference cookie when URI is in availableThemes", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const cookieHeader = "atbb-color-scheme=dark; atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark"; const result = await resolveTheme(APPVIEW, cookieHeader, undefined); expect(result.tokens["color-bg"]).toBe("#111"); expect(result.colorScheme).toBe("dark"); // Verify that the user's theme was fetched (rkey 3lbldark) expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); }); it("falls back to forum default when preference cookie URI is not in availableThemes", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale-uri"; const result = await resolveTheme(APPVIEW, cookieHeader, undefined); // Preference URI is stale, so forum default is used expect(result.tokens["color-bg"]).toBe("#fff"); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey }); it("ignores preference cookie when policy has allowUserChoice: false", async () => { mockFetch .mockResolvedValueOnce(policyResponse({ allowUserChoice: false })) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale"; const result = await resolveTheme(APPVIEW, cookieHeader, undefined); // User choice is disabled, so forum default is used even though cookie is set expect(result.tokens["color-bg"]).toBe("#fff"); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey }); }); describe("resolveTheme — cache integration", () => { const mockFetch = vi.fn(); const APPVIEW = "http://localhost:3001"; const TTL_MS = 60_000; beforeEach(() => { vi.stubGlobal("fetch", mockFetch); }); afterEach(() => { mockFetch.mockReset(); vi.unstubAllGlobals(); }); function policyResponse() { return { ok: true, json: () => Promise.resolve({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], }), }; } function themeResponse(colorScheme: "light" | "dark", cid: string) { return { ok: true, json: () => Promise.resolve({ cid, tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, cssOverrides: null, fontUrls: null, }), }; } it("policy cache hit skips policy fetch on second call", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined, cache); await resolveTheme(APPVIEW, undefined, undefined, cache); // Both policy and theme are cached after the first call — second call makes no fetches expect(mockFetch).toHaveBeenCalledTimes(2); // policy (1) + theme (1), both from first call }); it("theme cache hit skips theme fetch on second call", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined, cache); // Second call: policy is cached, theme is cached — zero fetches mockFetch.mockClear(); await resolveTheme(APPVIEW, undefined, undefined, cache); expect(mockFetch).not.toHaveBeenCalled(); }); it("cache returns correct tokens on second call without fetch", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const first = await resolveTheme(APPVIEW, undefined, undefined, cache); const second = await resolveTheme(APPVIEW, undefined, undefined, cache); expect(second.tokens["color-bg"]).toBe("#fff"); expect(second.tokens).toEqual(first.tokens); }); it("light and dark are cached independently — color scheme determines which is served", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")) // Dark request: policy is cached, but dark theme is not yet .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const light = await resolveTheme(APPVIEW, undefined, undefined, cache); const dark = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined, cache); expect(light.colorScheme).toBe("light"); expect(light.tokens["color-bg"]).toBe("#fff"); expect(dark.colorScheme).toBe("dark"); expect(dark.tokens["color-bg"]).toBe("#111"); // policy (1) + light theme (1) + dark theme (1) = 3 fetches expect(mockFetch).toHaveBeenCalledTimes(3); }); it("stale cache CID triggers eviction, fresh fetch, and logs warning", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined, cache); // Update cached policy to reflect a new CID (simulates admin updating the theme) cache.setPolicy({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], }); mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); const mockLogger = vi.mocked(logger); const result = await resolveTheme(APPVIEW, undefined, undefined, cache); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("stale CID"), expect.objectContaining({ expectedCid: "bafynew", cachedCid: "bafylight" }) ); expect(result.tokens["color-bg"]).toBe("#fff"); expect(mockFetch).toHaveBeenCalledTimes(3); // initial policy+theme + 1 fresh theme }); it("stale CID + failed fresh fetch falls back and evicts so next request retries", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined, cache); // Update policy to reflect a new CID cache.setPolicy({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], }); // Fresh fetch fails (AppView outage) mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); const fallbackResult = await resolveTheme(APPVIEW, undefined, undefined, cache); // Falls back to FALLBACK_THEME — stale data is not served expect(fallbackResult.tokens).toEqual(FALLBACK_THEME.tokens); // On the NEXT request: stale entry was evicted, so a fresh fetch is attempted again // (rather than re-detecting stale CID and looping forever) mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); const recoveredResult = await resolveTheme(APPVIEW, undefined, undefined, cache); expect(recoveredResult.tokens["color-bg"]).toBe("#fff"); expect(mockFetch).toHaveBeenCalledTimes(4); // initial 2 + failed fetch + recovered fetch }); it("cache repopulated after stale-CID fresh fetch — third call makes no fetches", async () => { const cache = new ThemeCache(TTL_MS); mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined, cache); cache.setPolicy({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], }); mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); await resolveTheme(APPVIEW, undefined, undefined, cache); // triggers fresh fetch, repopulates cache mockFetch.mockClear(); await resolveTheme(APPVIEW, undefined, undefined, cache); // should be a full cache hit expect(mockFetch).not.toHaveBeenCalled(); }); });