WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2import {
3 detectColorScheme,
4 parseRkeyFromUri,
5 resolveUserThemePreference,
6 FALLBACK_THEME,
7 fallbackForScheme,
8 resolveTheme,
9} from "../theme-resolution.js";
10import { ThemeCache } from "../theme-cache.js";
11import { logger } from "../logger.js";
12
13vi.mock("../logger.js", () => ({
14 logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
15}));
16
17describe("detectColorScheme", () => {
18 it("returns 'light' by default when no cookie or hint", () => {
19 expect(detectColorScheme(undefined, undefined)).toBe("light");
20 });
21
22 it("reads atbb-color-scheme=dark from cookie", () => {
23 expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark");
24 });
25
26 it("reads atbb-color-scheme=light from cookie", () => {
27 expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light");
28 });
29
30 it("prefers cookie over client hint", () => {
31 expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light");
32 });
33
34 it("falls back to client hint when no cookie", () => {
35 expect(detectColorScheme(undefined, "dark")).toBe("dark");
36 });
37
38 it("ignores unrecognized hint values and returns 'light'", () => {
39 expect(detectColorScheme(undefined, "no-preference")).toBe("light");
40 });
41
42 it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => {
43 // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched.
44 // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted.
45 expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light");
46 });
47});
48
49describe("parseRkeyFromUri", () => {
50 it("extracts rkey from valid AT URI", () => {
51 expect(
52 parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc")
53 ).toBe("3lblthemeabc");
54 });
55
56 it("returns null for URI with no rkey segment", () => {
57 expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull();
58 });
59
60 it("returns null for malformed URI", () => {
61 expect(parseRkeyFromUri("not-a-uri")).toBeNull();
62 });
63
64 it("returns null for empty string", () => {
65 expect(parseRkeyFromUri("")).toBeNull();
66 });
67});
68
69describe("resolveUserThemePreference", () => {
70 const availableThemes = [
71 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" },
72 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" },
73 ];
74
75 it("returns null when allowUserChoice is false", () => {
76 const result = resolveUserThemePreference(
77 "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight",
78 "light",
79 availableThemes,
80 false
81 );
82 expect(result).toBeNull();
83 });
84
85 it("returns atbb-light-theme URI when cookie matches and is in availableThemes", () => {
86 const result = resolveUserThemePreference(
87 "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight",
88 "light",
89 availableThemes,
90 true
91 );
92 expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbllight");
93 });
94
95 it("returns atbb-dark-theme URI when cookie matches and is in availableThemes", () => {
96 const result = resolveUserThemePreference(
97 "atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark",
98 "dark",
99 availableThemes,
100 true
101 );
102 expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbldark");
103 });
104
105 it("returns null when cookie URI is not in availableThemes (stale/removed)", () => {
106 const result = resolveUserThemePreference(
107 "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale",
108 "light",
109 availableThemes,
110 true
111 );
112 expect(result).toBeNull();
113 });
114
115 it("returns null when cookieHeader is undefined", () => {
116 const result = resolveUserThemePreference(
117 undefined,
118 "light",
119 availableThemes,
120 true
121 );
122 expect(result).toBeNull();
123 });
124
125 it("returns null when cookie value is empty string after cookie name", () => {
126 const result = resolveUserThemePreference(
127 "atbb-light-theme=",
128 "light",
129 availableThemes,
130 true
131 );
132 expect(result).toBeNull();
133 });
134
135 it("does not match x-atbb-light-theme as a cookie prefix", () => {
136 const result = resolveUserThemePreference(
137 "x-atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight",
138 "light",
139 availableThemes,
140 true
141 );
142 expect(result).toBeNull();
143 });
144});
145
146describe("FALLBACK_THEME", () => {
147 it("uses neobrutal-light tokens", () => {
148 expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8");
149 expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00");
150 });
151
152 it("has light colorScheme", () => {
153 expect(FALLBACK_THEME.colorScheme).toBe("light");
154 });
155
156 it("includes Google Fonts URL for Space Grotesk", () => {
157 expect(FALLBACK_THEME.fontUrls).toEqual(
158 expect.arrayContaining([expect.stringContaining("Space+Grotesk")])
159 );
160 });
161
162 it("has null cssOverrides", () => {
163 expect(FALLBACK_THEME.cssOverrides).toBeNull();
164 });
165});
166
167describe("fallbackForScheme", () => {
168 it("returns light tokens for light color scheme", () => {
169 const result = fallbackForScheme("light");
170 expect(result.tokens["color-bg"]).toBe("#f5f0e8");
171 expect(result.colorScheme).toBe("light");
172 });
173
174 it("returns dark tokens for dark color scheme", () => {
175 const result = fallbackForScheme("dark");
176 expect(result.tokens["color-bg"]).toBe("#1a1a1a");
177 expect(result.colorScheme).toBe("dark");
178 });
179});
180
181describe("resolveTheme", () => {
182 const mockFetch = vi.fn();
183 const mockLogger = vi.mocked(logger);
184 const APPVIEW = "http://localhost:3001";
185
186 beforeEach(() => {
187 vi.stubGlobal("fetch", mockFetch);
188 mockLogger.warn.mockClear();
189 mockLogger.error.mockClear();
190 });
191
192 afterEach(() => {
193 mockFetch.mockReset();
194 vi.unstubAllGlobals();
195 });
196
197 function policyResponse(overrides: object = {}) {
198 return {
199 ok: true,
200 json: () =>
201 Promise.resolve({
202 defaultLightThemeUri:
203 "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
204 defaultDarkThemeUri:
205 "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
206 allowUserChoice: true,
207 availableThemes: [
208 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" },
209 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
210 ],
211 ...overrides,
212 }),
213 };
214 }
215
216 function themeResponse(colorScheme: "light" | "dark", cid: string) {
217 return {
218 ok: true,
219 json: () =>
220 Promise.resolve({
221 cid,
222 tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" },
223 cssOverrides: null,
224 fontUrls: null,
225 colorScheme,
226 }),
227 };
228 }
229
230 it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => {
231 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
232 const result = await resolveTheme(APPVIEW, undefined, undefined);
233 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
234 expect(result.colorScheme).toBe("light");
235 expect(mockLogger.warn).toHaveBeenCalledWith(
236 expect.stringContaining("non-ok status"),
237 expect.objectContaining({ operation: "resolveTheme", status: 404 })
238 );
239 });
240
241 it("returns dark fallback tokens when policy fails and dark cookie set", async () => {
242 mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
243 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
244 expect(result.tokens).toEqual(fallbackForScheme("dark").tokens);
245 expect(result.colorScheme).toBe("dark");
246 expect(mockLogger.warn).toHaveBeenCalledWith(
247 expect.stringContaining("non-ok status"),
248 expect.any(Object)
249 );
250 });
251
252 it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => {
253 mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null }));
254 const result = await resolveTheme(APPVIEW, undefined, undefined);
255 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
256 });
257
258 it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => {
259 mockFetch.mockResolvedValueOnce(
260 policyResponse({ defaultLightThemeUri: "malformed-uri" })
261 );
262 const result = await resolveTheme(APPVIEW, undefined, undefined);
263 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
264 // Only one fetch should happen (policy only — no theme fetch)
265 expect(mockFetch).toHaveBeenCalledTimes(1);
266 });
267
268 it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => {
269 mockFetch
270 .mockResolvedValueOnce(policyResponse())
271 .mockResolvedValueOnce({ ok: false, status: 404 });
272 const result = await resolveTheme(APPVIEW, undefined, undefined);
273 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
274 expect(mockLogger.warn).toHaveBeenCalledWith(
275 expect.stringContaining("non-ok status"),
276 expect.objectContaining({ operation: "resolveTheme", status: 404 })
277 );
278 });
279
280 it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => {
281 mockFetch
282 .mockResolvedValueOnce(policyResponse())
283 .mockResolvedValueOnce(themeResponse("light", "WRONG_CID"));
284 const result = await resolveTheme(APPVIEW, undefined, undefined);
285 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
286 expect(logger.warn).toHaveBeenCalledWith(
287 expect.stringContaining("CID mismatch"),
288 expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" })
289 );
290 });
291
292 it("resolves the light theme on happy path (no cookie)", async () => {
293 mockFetch
294 .mockResolvedValueOnce(policyResponse())
295 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
296 const result = await resolveTheme(APPVIEW, undefined, undefined);
297 expect(result.tokens["color-bg"]).toBe("#fff");
298 expect(result.colorScheme).toBe("light");
299 expect(result.cssOverrides).toBeNull();
300 expect(result.fontUrls).toBeNull();
301 });
302
303 it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => {
304 mockFetch
305 .mockResolvedValueOnce(policyResponse())
306 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
307 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
308 expect(result.tokens["color-bg"]).toBe("#111");
309 expect(result.colorScheme).toBe("dark");
310 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark"));
311 });
312
313 it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => {
314 mockFetch
315 .mockResolvedValueOnce(policyResponse())
316 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
317 const result = await resolveTheme(APPVIEW, undefined, "dark");
318 expect(result.colorScheme).toBe("dark");
319 });
320
321 it("returns FALLBACK_THEME and logs error on network exception", async () => {
322 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
323 const result = await resolveTheme(APPVIEW, undefined, undefined);
324 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
325 expect(logger.error).toHaveBeenCalledWith(
326 expect.stringContaining("Theme policy fetch failed"),
327 expect.objectContaining({ operation: "resolveTheme" })
328 );
329 });
330
331 it("returns dark fallback tokens when network exception occurs with dark cookie", async () => {
332 // Regression: fallbackForScheme() must return dark tokens when the detected scheme is dark.
333 // Previously, all fallback paths returned FALLBACK_THEME (light tokens) regardless of scheme.
334 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
335 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
336 expect(result.tokens).toEqual(fallbackForScheme("dark").tokens);
337 expect(result.colorScheme).toBe("dark");
338 expect(result.tokens).not.toEqual(FALLBACK_THEME.tokens);
339 });
340
341 it("re-throws programming errors (TypeError) rather than swallowing them", async () => {
342 // A TypeError from a bug in the code should propagate, not be silently logged.
343 // This TypeError comes from the fetch() mock itself (not from .json()), so it
344 // is caught by the policy-fetch try block and re-thrown as a programming error.
345 mockFetch.mockImplementationOnce(() => {
346 throw new TypeError("Cannot read properties of null");
347 });
348 await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError);
349 });
350
351 it("passes cssOverrides and fontUrls through from theme response", async () => {
352 mockFetch
353 .mockResolvedValueOnce(policyResponse())
354 .mockResolvedValueOnce({
355 ok: true,
356 json: () =>
357 Promise.resolve({
358 cid: "bafylight",
359 tokens: { "color-bg": "#fff" },
360 cssOverrides: ".btn { font-weight: 700; }",
361 fontUrls: ["https://fonts.example.com/font.css"],
362 colorScheme: "light",
363 }),
364 });
365 const result = await resolveTheme(APPVIEW, undefined, undefined);
366 expect(result.cssOverrides).toBe(".btn { font-weight: 700; }");
367 expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]);
368 });
369
370 it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => {
371 mockFetch.mockResolvedValueOnce({
372 ok: true,
373 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
374 });
375 const result = await resolveTheme(APPVIEW, undefined, undefined);
376 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
377 expect(mockLogger.error).toHaveBeenCalledWith(
378 expect.stringContaining("invalid JSON"),
379 expect.objectContaining({ operation: "resolveTheme" })
380 );
381 });
382
383 it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => {
384 mockFetch
385 .mockResolvedValueOnce(policyResponse())
386 .mockResolvedValueOnce({
387 ok: true,
388 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
389 });
390 const result = await resolveTheme(APPVIEW, undefined, undefined);
391 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
392 expect(mockLogger.error).toHaveBeenCalledWith(
393 expect.stringContaining("invalid JSON"),
394 expect.objectContaining({ operation: "resolveTheme" })
395 );
396 });
397
398 it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => {
399 mockFetch
400 .mockResolvedValueOnce(policyResponse({ availableThemes: [] }))
401 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
402 await resolveTheme(APPVIEW, undefined, undefined);
403 expect(mockLogger.warn).toHaveBeenCalledWith(
404 expect.stringContaining("not in availableThemes"),
405 expect.objectContaining({
406 operation: "resolveTheme",
407 themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
408 })
409 );
410 });
411
412 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => {
413 // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".."
414 // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch
415 mockFetch.mockResolvedValueOnce(
416 policyResponse({
417 defaultLightThemeUri: "at://did/col/../../secret",
418 })
419 );
420 const result = await resolveTheme(APPVIEW, undefined, undefined);
421 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
422 // Only the policy fetch should have been made (no theme fetch)
423 expect(mockFetch).toHaveBeenCalledTimes(1);
424 });
425
426 it("no cache provided — behaves identically to pre-cache implementation", async () => {
427 mockFetch
428 .mockResolvedValueOnce(policyResponse())
429 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
430 const result = await resolveTheme(APPVIEW, undefined, undefined, undefined);
431 expect(result.tokens["color-bg"]).toBe("#fff");
432 expect(mockFetch).toHaveBeenCalledTimes(2);
433 });
434
435 it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => {
436 // Live refs have no CID — canonical atbb.space presets ship this way.
437 // The CID integrity check must be skipped when expectedCid is null.
438 mockFetch
439 .mockResolvedValueOnce(
440 policyResponse({
441 availableThemes: [
442 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid
443 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid
444 ],
445 })
446 )
447 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
448
449 const result = await resolveTheme(APPVIEW, undefined, undefined);
450
451 // Theme resolved successfully — live ref does not trigger CID mismatch
452 expect(result.tokens["color-bg"]).toBe("#fff");
453 expect(result.colorScheme).toBe("light");
454 expect(mockLogger.warn).not.toHaveBeenCalledWith(
455 expect.stringContaining("CID mismatch"),
456 expect.any(Object)
457 );
458 });
459
460 it("resolves light preference cookie when URI is in availableThemes", async () => {
461 mockFetch
462 .mockResolvedValueOnce(policyResponse())
463 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
464
465 const cookieHeader = "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight";
466 const result = await resolveTheme(APPVIEW, cookieHeader, undefined);
467
468 expect(result.tokens["color-bg"]).toBe("#fff");
469 expect(result.colorScheme).toBe("light");
470 // Verify that the user's theme was fetched (rkey 3lbllight) not the forum default
471 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight"));
472 });
473
474 it("resolves dark preference cookie when URI is in availableThemes", async () => {
475 mockFetch
476 .mockResolvedValueOnce(policyResponse())
477 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
478
479 const cookieHeader = "atbb-color-scheme=dark; atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark";
480 const result = await resolveTheme(APPVIEW, cookieHeader, undefined);
481
482 expect(result.tokens["color-bg"]).toBe("#111");
483 expect(result.colorScheme).toBe("dark");
484 // Verify that the user's theme was fetched (rkey 3lbldark)
485 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark"));
486 });
487
488 it("falls back to forum default when preference cookie URI is not in availableThemes", async () => {
489 mockFetch
490 .mockResolvedValueOnce(policyResponse())
491 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
492
493 const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale-uri";
494 const result = await resolveTheme(APPVIEW, cookieHeader, undefined);
495
496 // Preference URI is stale, so forum default is used
497 expect(result.tokens["color-bg"]).toBe("#fff");
498 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey
499 });
500
501 it("ignores preference cookie when policy has allowUserChoice: false", async () => {
502 mockFetch
503 .mockResolvedValueOnce(policyResponse({ allowUserChoice: false }))
504 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
505
506 const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale";
507 const result = await resolveTheme(APPVIEW, cookieHeader, undefined);
508
509 // User choice is disabled, so forum default is used even though cookie is set
510 expect(result.tokens["color-bg"]).toBe("#fff");
511 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey
512 });
513});
514
515describe("resolveTheme — cache integration", () => {
516 const mockFetch = vi.fn();
517 const APPVIEW = "http://localhost:3001";
518 const TTL_MS = 60_000;
519
520 beforeEach(() => {
521 vi.stubGlobal("fetch", mockFetch);
522 });
523
524 afterEach(() => {
525 mockFetch.mockReset();
526 vi.unstubAllGlobals();
527 });
528
529 function policyResponse() {
530 return {
531 ok: true,
532 json: () =>
533 Promise.resolve({
534 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
535 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
536 allowUserChoice: true,
537 availableThemes: [
538 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" },
539 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
540 ],
541 }),
542 };
543 }
544
545 function themeResponse(colorScheme: "light" | "dark", cid: string) {
546 return {
547 ok: true,
548 json: () =>
549 Promise.resolve({
550 cid,
551 tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" },
552 cssOverrides: null,
553 fontUrls: null,
554 }),
555 };
556 }
557
558 it("policy cache hit skips policy fetch on second call", async () => {
559 const cache = new ThemeCache(TTL_MS);
560 mockFetch
561 .mockResolvedValueOnce(policyResponse())
562 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
563
564 await resolveTheme(APPVIEW, undefined, undefined, cache);
565 await resolveTheme(APPVIEW, undefined, undefined, cache);
566
567 // Both policy and theme are cached after the first call — second call makes no fetches
568 expect(mockFetch).toHaveBeenCalledTimes(2); // policy (1) + theme (1), both from first call
569 });
570
571 it("theme cache hit skips theme fetch on second call", async () => {
572 const cache = new ThemeCache(TTL_MS);
573 mockFetch
574 .mockResolvedValueOnce(policyResponse())
575 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
576
577 await resolveTheme(APPVIEW, undefined, undefined, cache);
578 // Second call: policy is cached, theme is cached — zero fetches
579 mockFetch.mockClear();
580 await resolveTheme(APPVIEW, undefined, undefined, cache);
581
582 expect(mockFetch).not.toHaveBeenCalled();
583 });
584
585 it("cache returns correct tokens on second call without fetch", async () => {
586 const cache = new ThemeCache(TTL_MS);
587 mockFetch
588 .mockResolvedValueOnce(policyResponse())
589 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
590
591 const first = await resolveTheme(APPVIEW, undefined, undefined, cache);
592 const second = await resolveTheme(APPVIEW, undefined, undefined, cache);
593
594 expect(second.tokens["color-bg"]).toBe("#fff");
595 expect(second.tokens).toEqual(first.tokens);
596 });
597
598 it("light and dark are cached independently — color scheme determines which is served", async () => {
599 const cache = new ThemeCache(TTL_MS);
600 mockFetch
601 .mockResolvedValueOnce(policyResponse())
602 .mockResolvedValueOnce(themeResponse("light", "bafylight"))
603 // Dark request: policy is cached, but dark theme is not yet
604 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
605
606 const light = await resolveTheme(APPVIEW, undefined, undefined, cache);
607 const dark = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined, cache);
608
609 expect(light.colorScheme).toBe("light");
610 expect(light.tokens["color-bg"]).toBe("#fff");
611 expect(dark.colorScheme).toBe("dark");
612 expect(dark.tokens["color-bg"]).toBe("#111");
613 // policy (1) + light theme (1) + dark theme (1) = 3 fetches
614 expect(mockFetch).toHaveBeenCalledTimes(3);
615 });
616
617 it("stale cache CID triggers eviction, fresh fetch, and logs warning", async () => {
618 const cache = new ThemeCache(TTL_MS);
619 mockFetch
620 .mockResolvedValueOnce(policyResponse())
621 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
622 await resolveTheme(APPVIEW, undefined, undefined, cache);
623
624 // Update cached policy to reflect a new CID (simulates admin updating the theme)
625 cache.setPolicy({
626 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
627 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
628 allowUserChoice: true,
629 availableThemes: [
630 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" },
631 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
632 ],
633 });
634
635 mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew"));
636
637 const mockLogger = vi.mocked(logger);
638 const result = await resolveTheme(APPVIEW, undefined, undefined, cache);
639
640 expect(mockLogger.warn).toHaveBeenCalledWith(
641 expect.stringContaining("stale CID"),
642 expect.objectContaining({ expectedCid: "bafynew", cachedCid: "bafylight" })
643 );
644 expect(result.tokens["color-bg"]).toBe("#fff");
645 expect(mockFetch).toHaveBeenCalledTimes(3); // initial policy+theme + 1 fresh theme
646 });
647
648 it("stale CID + failed fresh fetch falls back and evicts so next request retries", async () => {
649 const cache = new ThemeCache(TTL_MS);
650 mockFetch
651 .mockResolvedValueOnce(policyResponse())
652 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
653 await resolveTheme(APPVIEW, undefined, undefined, cache);
654
655 // Update policy to reflect a new CID
656 cache.setPolicy({
657 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
658 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
659 allowUserChoice: true,
660 availableThemes: [
661 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" },
662 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
663 ],
664 });
665
666 // Fresh fetch fails (AppView outage)
667 mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
668 const fallbackResult = await resolveTheme(APPVIEW, undefined, undefined, cache);
669
670 // Falls back to FALLBACK_THEME — stale data is not served
671 expect(fallbackResult.tokens).toEqual(FALLBACK_THEME.tokens);
672
673 // On the NEXT request: stale entry was evicted, so a fresh fetch is attempted again
674 // (rather than re-detecting stale CID and looping forever)
675 mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew"));
676 const recoveredResult = await resolveTheme(APPVIEW, undefined, undefined, cache);
677
678 expect(recoveredResult.tokens["color-bg"]).toBe("#fff");
679 expect(mockFetch).toHaveBeenCalledTimes(4); // initial 2 + failed fetch + recovered fetch
680 });
681
682 it("cache repopulated after stale-CID fresh fetch — third call makes no fetches", async () => {
683 const cache = new ThemeCache(TTL_MS);
684 mockFetch
685 .mockResolvedValueOnce(policyResponse())
686 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
687 await resolveTheme(APPVIEW, undefined, undefined, cache);
688
689 cache.setPolicy({
690 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
691 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
692 allowUserChoice: true,
693 availableThemes: [
694 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" },
695 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
696 ],
697 });
698
699 mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew"));
700 await resolveTheme(APPVIEW, undefined, undefined, cache); // triggers fresh fetch, repopulates cache
701
702 mockFetch.mockClear();
703 await resolveTheme(APPVIEW, undefined, undefined, cache); // should be a full cache hit
704
705 expect(mockFetch).not.toHaveBeenCalled();
706 });
707});