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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at root/atb-56-theme-caching-layer 550 lines 22 kB view raw
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});