235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|