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 { idFromWebkey, 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; CLOCKED_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: an overstated total is 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("pacing", () => { it("paces diigo calls only: a cache hit doesn't wait", async () => { const stub = testEnv.CLOCKED_DO.get(testEnv.CLOCKED_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); await stub.page(1, PAGE_SIZE); const afterMiss = (await stub.sleeps()).length; expect(afterMiss).toBeGreaterThan(0); for (let i = 0; i < 3; i += 1) expect((await stub.page(1, PAGE_SIZE)).cached).toBe(true); expect(await stub.sleeps()).toHaveLength(afterMiss); }); }); describe("page numbering (schema v2)", () => { it("asks diigo for page_num n-1: page 1 is the newest page", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); const first = (await stub.page(1, PAGE_SIZE)).items.map((j) => (JSON.parse(j) as { title: string }).title); expect(first[0]).toBe("bookmark 1"); }); it("migrates v1 storage: a stored 'page n' was really page n+1, so it is renumbered, not refetched", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); // the shape v1 left: a page 1 holding what diigo's page_num=1 // returned (really page 2), and no schema marker; then a fresh // activation, whose constructor migrates await runInDurableObject(stub, (_obj, state) => { const sql = state.storage.sql; sql.exec("insert into items values ('a', 'https://a.example/', 'old page one, first', 0, '{\"title\":\"A\"}', '1')"); sql.exec("insert into items values ('b', 'https://b.example/', 'old page one, second', 0, '{\"title\":\"B\"}', '1')"); sql.exec("insert into pages values (1, 100, 250, '2026-10-07T00:00:00.000Z')"); sql.exec("insert into page_members values (1, 1, 'a'), (1, 2, 'b')"); state.storage.kv.delete("schema"); }); await evictDurableObject(stub); const before = await testEnv.WEBAPP.calls(); const two = await stub.page(2, PAGE_SIZE); expect(two.cached).toBe(true); expect(two.items.map((j) => (JSON.parse(j) as { title: string }).title)).toEqual(["A", "B"]); expect(await testEnv.WEBAPP.calls()).toBe(before); // page 1 was never fetched under v1: now it is a miss expect((await stub.page(1, PAGE_SIZE)).cached).toBe(false); await runInDurableObject(stub, (_obj, state) => { expect(state.storage.kv.get("schema")).toBe(2); }); }); }); describe("invalidateRecent", () => { it("drops the pages holding anything updated in the last N days, and only those", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); // deep_dee: one bookmark a day, newest first; page 1 = days 0–99, // page 2 = days 100–199, page 3 = days 200–299 await stub.setup({ webappId: "fake-deep-webapp" }); for (const n of [1, 2, 3]) await stub.page(n, PAGE_SIZE); expect(await stub.invalidateRecent(150)).toEqual({ pages: 2 }); expect((await stub.page(3, PAGE_SIZE)).cached).toBe(true); expect((await stub.page(2, PAGE_SIZE)).cached).toBe(false); expect((await stub.page(1, PAGE_SIZE)).cached).toBe(false); }); it("drops nothing when nothing cached is that recent, and refuses a bad day count", async () => { const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId()); await stub.setup({ webappId: WEBAPP_ID }); // the plain account's dates are in 2023 await stub.page(1, PAGE_SIZE); expect(await stub.invalidateRecent(7)).toEqual({ pages: 0 }); expect((await stub.page(1, PAGE_SIZE)).cached).toBe(true); const refused = await stub.invalidateRecent(0).then(() => "", (e: unknown) => String(e)); expect(refused).toMatch(/days/); }); }); 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("the public page only reads: an unfetched page says so; the owner's fill makes it appear", 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, publicId } = (await wired.json()) as { username: string; publicId: string }; expect(username).toBe("alice_99"); // page 2 was never fetched: the public page can't fetch it const before = await testEnv.WEBAPP.calls(); const unfetched = await (await call(`/public/${username}?page=2`)).text(); expect(unfetched).toContain('
not fetched yet"); expect(await testEnv.WEBAPP.calls()).toBe(before); // the owner's side stores it (here, directly on the public cache) const pub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.idFromString(idFromWebkey(publicId))); await pub.page(2, PAGE_SIZE); const html = await (await call(`/public/${username}?page=2`)).text(); expect(html).toMatch(/stored \d{4}-\d\d-\d\dT[\d:.]+Z<\/strong>/); // PAGE_SIZE 100: page 2 is bookmarks 101..200 expect(html).toContain(">bookmark 130<"); // the date comes first, in toISOString() form expect(html).toMatch(/