Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
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<Cache> {
public store = new Map<string, Response>();
public async match(
request: RequestInfo | URL
): Promise<Response | undefined> {
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<void> {
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);
});
});