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
at user-theme-preferences 707 lines 28 kB view raw
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});