import { env } from "cloudflare:workers"; import { evictDurableObject, runInDurableObject } from "cloudflare:test"; import { describe, expect, it, vi } from "vitest"; import worker from "../src/index.js"; import { toWebkey } from "../src/webkeys.js"; import { FETCH_BUDGET } from "../src/query.js"; import { PAGE_SIZE } from "../src/cache.js"; // matches tests/fake-webapp.js: 30 items, every third private const WEBAPP_ID = "fake-webapp-id"; type TestEnv = { CACHE_DO: DurableObjectNamespace; PUBLIC_DO: DurableObjectNamespace; WEBAPP: { calls(): Promise }; }; const testEnv = env as unknown as TestEnv; const parse = (items: string[]) => items.map((j) => JSON.parse(j) as Record); describe("PublicCache", () => { it("on a miss asks the webapp and keeps only shared items; a hit makes no call", async () => { const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); const before = await testEnv.WEBAPP.calls(); const miss = await stub.page(1, 24); expect(miss.cached).toBe(false); expect(miss.total).toBe(250); // diigo page 1 = items 1..24, of which 8 are private expect(parse(miss.items).map((i) => i.private)).toEqual(Array(16).fill(false)); const afterMiss = await testEnv.WEBAPP.calls(); expect(afterMiss).toBeGreaterThan(before); const hit = await stub.page(1, 24); expect(hit.cached).toBe(true); expect(hit.items).toEqual(miss.items); expect(await testEnv.WEBAPP.calls()).toBe(afterMiss); }); it("logs each page read as a hit or a miss (fetched vs kept), without the cache id", async () => { const spy = vi.spyOn(console, "log"); try { const id = testEnv.PUBLIC_DO.newUniqueId(); const stub = testEnv.PUBLIC_DO.get(id); await stub.setup({ webappId: WEBAPP_ID }); await stub.page(1, 24); await stub.page(1, 24); const cacheLogs = spy.mock.calls.map((c) => c[0]).filter((e) => e?.event === "cache"); expect(cacheLogs).toEqual([ { event: "cache", kind: "public", page: 1, outcome: "miss", fetched: 24, kept: 16 }, { event: "cache", kind: "public", page: 1, outcome: "hit", items: 16 }, ]); expect(JSON.stringify(spy.mock.calls)).not.toContain(id.toString()); } finally { spy.mockRestore(); } }); it("stores no private rows at all", async () => { const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); await stub.page(1, 24); await stub.page(2, 24); await runInDurableObject(stub, (_obj, state) => { const rows = state.storage.sql.exec<{ n: number }>("select count(*) as n from items where private = 1").toArray(); expect(rows[0].n).toBe(0); }); }); }); describe("page size", () => { it("refuses, loudly, a short page that isn't the last (diigo capping the size would skip items)", async () => { const spy = vi.spyOn(console, "error"); try { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: "fake-capped-webapp" }); // settle the RPC promise ourselves: expect().rejects on a DO stub's // promise leaves the rejection unhandled const failure = () => stub.page(1, PAGE_SIZE).then(() => null, (e: unknown) => String(e)); expect(await failure()).toMatch(/returned 64 items for page 1 of 3 \(asked for 100\), then 64 for page 2/); expect(spy.mock.calls.map((c) => c[0]).filter((e) => e?.outcome === "short-page")).toHaveLength(1); // nothing was stored: asking again refuses again expect(await failure()).toMatch(/page size not honored/); } finally { spy.mockRestore(); } }); it("a short page whose next page is empty is the real end: diigo's total overstated, noted not refused", async () => { const spy = vi.spyOn(console, "log"); try { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: "fake-overstated-webapp" }); // 250 items claimed as 310: page 3 holds the last 50, "of 4" const page = await stub.page(3, PAGE_SIZE); expect(page.items).toHaveLength(50); const notes = spy.mock.calls.map((c) => c[0]).filter((e) => e?.outcome === "total-overstated"); expect(notes).toEqual([expect.objectContaining({ page: 3, total: 310, actual: 250 })]); } finally { spy.mockRestore(); } }); it("the last page may be short", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); expect((await stub.page(3, PAGE_SIZE)).items).toHaveLength(250 - 2 * PAGE_SIZE); }); }); describe("eviction", () => { it("a cache miss leaves no undisposed RPC stubs to block eviction", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); await stub.page(1, 24); await evictDurableObject(stub); expect((await stub.page(1, 24)).cached).toBe(true); }, 3000); }); describe("DiigoCache", () => { it("keeps every item of a page, each under its own link_id", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); const page = await stub.page(1, 24); const ids = parse(page.items).map((i) => i.link_id); expect(ids).toHaveLength(24); expect(new Set(ids).size).toBe(24); expect(parse(page.items).filter((i) => i.private)).toHaveLength(8); }); }); describe("page replay order", () => { it("a hit replays diigo's fetch-time order (page_members), not a column sort", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); const titles = (items: string[]) => parse(items).map((i) => i.title); const expected = Array.from({ length: 24 }, (_, k) => `bookmark ${k + 1}`); const miss = await stub.page(1, 24); expect(titles(miss.items)).toEqual(expected); // page 2's etch upserts other rows; page 1's replay must not shift await stub.page(2, 24); const hit = await stub.page(1, 24); expect(hit.cached).toBe(true); expect(titles(hit.items)).toEqual(expected); }); }); describe("worker routes", () => { const call = (path: string, init?: RequestInit) => worker.fetch(new Request(`https://diigo-cache.test${path}`, init), env as never); it("wires a public cache whose page fills itself on a miss", async () => { const wired = await call("/_wire", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ webappId: WEBAPP_ID, mode: "public" }), }); expect(wired.status).toBe(200); const { username } = (await wired.json()) as { username: string }; expect(username).toBe("alice_99"); // page 2 was never fetched by anyone: the public cache asks the webapp const html = await (await call(`/public/${username}?page=2`)).text(); expect(html).toContain('
cache: MISS"); const again = await (await call(`/public/${username}?page=2`)).text(); expect(again).toContain("cache: HIT"); // PAGE_SIZE 100: page 2 is bookmarks 101..200 expect(html).toContain(">bookmark 130<"); // the date comes first, in toISOString() form expect(html).toMatch(/