import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner"; import type { Clock } from "../types/common"; import { cacheFirst, cacheOnly, networkFirst, networkOnly, staleWhileRevalidate, } from "./caching-strategies"; import type { StrategyContext } from "./caching-strategies.types"; class FakeCache implements Partial { public store = new Map(); public async match( request: RequestInfo | URL ): Promise { const key = typeof request === "string" ? request : (request as Request).url; const r = this.store.get(key); return r === undefined ? undefined : r.clone(); } public async put( request: RequestInfo | URL, response: Response ): Promise { const key = typeof request === "string" ? request : (request as Request).url; this.store.set(key, response); } } function buildContext(opts: { fetcher: typeof fetch; clock?: Clock; }): StrategyContext { return { cache: new FakeCache() as unknown as Cache, fetcher: opts.fetcher, clock: opts.clock ?? { now: () => 1_000 }, }; } let calls: number; function counted(body: string, status = 200): typeof fetch { return ((_req: unknown) => { calls += 1; return Promise.resolve(new Response(body, { status })); }) as unknown as typeof fetch; } beforeEach(() => { calls = 0; }); describe("cacheFirst", () => { it("cacheFirst hits cache when present and falls through otherwise", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("net") }); const strategy = cacheFirst(); const req = new Request("https://x.test/a"); const a = await strategy(req, ctx); expect(await a.text()).toBe("net"); expect(calls).toBe(1); const b = await strategy(req, ctx); expect(await b.text()).toBe("net"); expect(calls).toBe(1); }); it("cacheFirst refetches when TTL expires", { timeout: 5000 }, async () => { let t = 0; const clock: Clock = { now: () => t }; const ctx = buildContext({ fetcher: counted("net"), clock }); const strategy = cacheFirst({ ttlMs: 100 }); const req = new Request("https://x.test/a"); await strategy(req, ctx); t = 200; await strategy(req, ctx); expect(calls).toBe(2); }); }); describe("networkFirst", () => { it("networkFirst falls back to cache on network failure", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("ok") }); const req = new Request("https://x.test/n"); const strategy = networkFirst(); // seed cache await strategy(req, ctx); // now make the fetcher reject (ctx as { fetcher: typeof fetch }).fetcher = (() => { return Promise.reject(new Error("down")); }) as unknown as typeof fetch; const response = await strategy(req, ctx); expect(response.status).toBe(200); expect(await response.text()).toBe("ok"); }); it("networkFirst returns 504 on hard offline with no cache", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: (() => Promise.reject(new Error("down"))) as unknown as typeof fetch, }); const req = new Request("https://x.test/n"); const response = await networkFirst()(req, ctx); expect(response.status).toBe(504); }); it("networkFirst returns 504 when only stale cache exists", { timeout: 5000, }, async () => { let t = 0; const clock: Clock = { now: () => t }; const ctx = buildContext({ fetcher: counted("ok"), clock }); const req = new Request("https://x.test/n"); const strategy = networkFirst({ ttlMs: 50 }); await strategy(req, ctx); t = 200; (ctx as { fetcher: typeof fetch }).fetcher = (() => Promise.reject(new Error("down"))) as unknown as typeof fetch; const response = await strategy(req, ctx); expect(response.status).toBe(504); }); }); describe("staleWhileRevalidate", () => { it("staleWhileRevalidate refreshes in the background", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("v1") }); const req = new Request("https://x.test/swr"); const strategy = staleWhileRevalidate(); const a = await strategy(req, ctx); expect(await a.text()).toBe("v1"); // change fetcher response and call again — should serve cached v1 but trigger revalidate (ctx as { fetcher: typeof fetch }).fetcher = ((_r: unknown) => { return Promise.resolve(new Response("v2")); }) as unknown as typeof fetch; const b = await strategy(req, ctx); expect(await b.text()).toBe("v1"); // give the background fetch a tick await new Promise((r) => setTimeout(r, 5)); const c = await strategy(req, ctx); expect(await c.text()).toBe("v2"); }); it("staleWhileRevalidate returns 504 on miss + fetch failure", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: (() => Promise.reject(new Error("down"))) as unknown as typeof fetch, }); const req = new Request("https://x.test/swr2"); const response = await staleWhileRevalidate()(req, ctx); expect(response.status).toBe(504); }); }); describe("cacheOnly", () => { it("cacheOnly returns 504 on miss", { timeout: 5000 }, async () => { const ctx = buildContext({ fetcher: counted("never") }); const response = await cacheOnly()( new Request("https://x.test/co"), ctx ); expect(response.status).toBe(504); expect(calls).toBe(0); }); it("cacheOnly returns the cached value when present", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("net") }); const req = new Request("https://x.test/co2"); await cacheFirst()(req, ctx); // seed const response = await cacheOnly()(req, ctx); expect(await response.text()).toBe("net"); }); }); describe("edge branches", () => { it("non-ok responses are returned without caching", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("err", 500) }); const req = new Request("https://x.test/err"); const strategy = cacheFirst(); const a = await strategy(req, ctx); expect(a.status).toBe(500); const b = await strategy(req, ctx); expect(b.status).toBe(500); expect(calls).toBe(2); }); it("cacheFirst returns cached when no TTL", { timeout: 5000 }, async () => { const ctx = buildContext({ fetcher: counted("seeded") }); const req = new Request("https://x.test/seeded"); const strategy = cacheFirst(); await strategy(req, ctx); const second = await strategy(req, ctx); expect(await second.text()).toBe("seeded"); expect(calls).toBe(1); }); it("treats cached responses without a timestamp header as fresh", { timeout: 5000, }, async () => { const ctx = buildContext({ fetcher: counted("fresh-net") }); const req = new Request("https://x.test/unstamped"); // Seed the cache directly with an unstamped response (no x-suaveplan-pwa-cached-at). await ctx.cache.put(req, new Response("pre-existing")); const strategy = cacheFirst({ ttlMs: 100 }); const response = await strategy(req, ctx); expect(await response.text()).toBe("pre-existing"); expect(calls).toBe(0); }); }); describe("networkOnly", () => { it("networkOnly never reads the cache", { timeout: 5000 }, async () => { const ctx = buildContext({ fetcher: counted("fresh") }); const req = new Request("https://x.test/no"); await cacheFirst()(req, ctx); // would seed cache const response = await networkOnly()(req, ctx); expect(await response.text()).toBe("fresh"); // 1 fetch from cacheFirst + 1 from networkOnly expect(calls).toBe(2); }); });