Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user