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