Every stored shared bookmark on one page, in the public page's microformats. Without JS that's the whole list, and the search form asks the server's filtered page. With JS, the newest 5 show and search hides and shows entries in place. The browser runs the server's own matcher: query.ts becomes query.js (JSDoc types, checkJs), which the worker imports as code and serves as text at /_query.js via a `?raw` import and a wrangler Text rule. `shown()` picks what the page shows; the page's script reads items back from each h-entry. Tests: shown(); /all lists all 270 stored shared items, none hidden; /_query.js is the module the worker runs; and client-side search over /all's parsed microformats finds what the server's ?q= finds. The script itself was checked once in headless Chromium (5 of 54 shown by default; ?q=fragments shows 8), not in CI. Generated-by: Claude Opus 5.5
501 lines
23 KiB
TypeScript
501 lines
23 KiB
TypeScript
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 { PAGE_SIZE } from "../src/cache.js";
|
||
import { mf2 } from "microformats-parser";
|
||
import { shown } from "../src/query.js";
|
||
import QUERY_JS from "../src/query.js?raw";
|
||
|
||
// matches tests/fake-webapp.js: 30 items, every third private
|
||
const WEBAPP_ID = "fake-webapp-id";
|
||
|
||
type TestEnv = {
|
||
CACHE_DO: DurableObjectNamespace<import("../src/index.js").DiigoCache>;
|
||
PUBLIC_DO: DurableObjectNamespace<import("../src/index.js").PublicCache>;
|
||
CLOCKED_DO: DurableObjectNamespace<import("./test-worker.js").FixedClockCache>;
|
||
WEBAPP: { calls(): Promise<number> };
|
||
};
|
||
const testEnv = env as unknown as TestEnv;
|
||
|
||
const parse = (items: string[]) => items.map((j) => JSON.parse(j) as Record<string, unknown>);
|
||
|
||
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('<form method="get"');
|
||
expect(unfetched).toContain("<strong>not fetched yet</strong>");
|
||
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(/<strong>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(/<li class="h-entry"><time class="dt-updated" datetime="\d{4}-\d\d-\d\dT[\d:.]+Z">[^<]+<\/time> <a /);
|
||
expect(html).not.toContain(">bookmark 129<"); // private (129 % 3 === 0)
|
||
|
||
const json = (await (
|
||
await call(`/public/${username}?page=2`, { headers: { accept: "application/json" } })
|
||
).json()) as { pages: number; stored: boolean; items: unknown[] };
|
||
expect(json.pages).toBe(3);
|
||
expect(json.stored).toBe(true);
|
||
expect(json.items).toHaveLength(67); // 101..200 minus its 33 multiples of 3
|
||
});
|
||
|
||
it("publishes a private cache later, without the cache id in the URL", async () => {
|
||
const cacheId = toWebkey(testEnv.CACHE_DO.newUniqueId());
|
||
const res = await call("/_publish", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ cacheId }),
|
||
});
|
||
// never wired: refused, not crashed
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
/** `yyyy-Www` for the ISO 8601 week holding `d` (UTC) */
|
||
function isoWeek(d: Date): string {
|
||
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||
const day = t.getUTCDay() || 7;
|
||
t.setUTCDate(t.getUTCDate() + 4 - day); // the week's Thursday names its year
|
||
const year = t.getUTCFullYear();
|
||
const week = Math.ceil(((t.getTime() - Date.UTC(year, 0, 1)) / 86400000 + 1) / 7);
|
||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||
}
|
||
|
||
describe("public page filters (goal 1d)", () => {
|
||
// the fake's dated account: "dated n" updated n-1 days + 1h ago;
|
||
// #weekly on every 7th; every 10th private; every 5th created 100
|
||
// days before it was updated. The public page only reads, so each
|
||
// test stores the pages it needs on the owner's side first.
|
||
const call = (path: string, init?: RequestInit) =>
|
||
worker.fetch(new Request(`https://diigo-cache.test${path}`, init), env as never);
|
||
/** wire a public cache to `webappId` and store diigo pages 1..`pages` */
|
||
const wireStored = async (webappId: string, pages: number) => {
|
||
const res = await call("/_wire", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ webappId, mode: "public" }),
|
||
});
|
||
expect(res.status).toBe(200);
|
||
const { username, publicId } = (await res.json()) as { username: string; publicId: string };
|
||
const pub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.idFromString(idFromWebkey(publicId)));
|
||
for (let n = 1; n <= pages; n += 1) await pub.page(n, PAGE_SIZE);
|
||
return { page: `/public/${username}`, pub };
|
||
};
|
||
const wireDated = async () => (await wireStored("fake-dated-webapp", 1)).page;
|
||
const titlesIn = (html: string) => [...html.matchAll(/>(dated \d+)<\/a>/g)].map((m) => m[1]);
|
||
const dated = (...ns: number[]) => ns.map((n) => `dated ${n}`);
|
||
|
||
it("?days=7 lists the last week", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=7`)).text();
|
||
expect(titlesIn(html)).toEqual(dated(1, 2, 3, 4, 5, 6, 7));
|
||
});
|
||
|
||
it("?by=created windows, lists, and dates by when it was bookmarked", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=7&by=created`)).text();
|
||
// 5 was updated this week but bookmarked 100 days earlier
|
||
expect(titlesIn(html)).toEqual(dated(1, 2, 3, 4, 6, 7));
|
||
const updated = await (await call(`${page}?days=7`)).text();
|
||
expect(titlesIn(updated)).toContain("dated 5");
|
||
});
|
||
|
||
it("?by=created over a longer window sorts by created, newest first", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=150&by=created&q=${encodeURIComponent("#weekly")}`)).text();
|
||
// weekly: 7,14,21,28,35,42,49,56; 35 was created ~134 days ago
|
||
// (100 days before its update), so it sorts after 56 (~55 days)
|
||
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 42, 49, 56, 35));
|
||
});
|
||
|
||
it("?days=300 pages the matches PAGE_SIZE at a time; links keep the filter", async () => {
|
||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||
// deep_dee: one a day; days=300 holds 1..300, minus every 10th (private)
|
||
const want = Array.from({ length: 300 }, (_, k) => k + 1).filter((n) => n % 10 !== 0);
|
||
const first = await (await call(`${page}?days=300`)).text();
|
||
expect(titlesIn(first)).toEqual(dated(...want.slice(0, PAGE_SIZE)));
|
||
expect(first).toContain(`href="${page}?days=300&page=2"`);
|
||
const second = await (await call(`${page}?days=300&page=2`)).text();
|
||
expect(titlesIn(second)).toEqual(dated(...want.slice(PAGE_SIZE, 2 * PAGE_SIZE)));
|
||
const third = await (await call(`${page}?days=300&page=3`)).text();
|
||
expect(titlesIn(third)).toEqual(dated(...want.slice(2 * PAGE_SIZE)));
|
||
});
|
||
|
||
it("?after=&before= selects a date range", async () => {
|
||
const page = await wireDated();
|
||
const day = (n: number) => new Date(Date.now() - (n - 1) * 24 * 60 * 60 * 1000 - 60 * 60 * 1000).toISOString().slice(0, 10);
|
||
// items are 24h apart, so each has its own UTC day: from dated 12's
|
||
// day (inclusive) up to dated 9's day (exclusive); 10 is private
|
||
const html = await (await call(`${page}?after=${day(12)}&before=${day(9)}`)).text();
|
||
expect(titlesIn(html)).toEqual(dated(11, 12));
|
||
});
|
||
|
||
it("?in= names an ISO week", async () => {
|
||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||
const week = isoWeek(new Date(Date.now() - 100 * 24 * 60 * 60 * 1000));
|
||
const html = await (await call(`${page}?in=${week}`)).text();
|
||
// one a day; every 10th is private
|
||
expect(titlesIn(html).length).toBeGreaterThanOrEqual(6);
|
||
expect(titlesIn(html).length).toBeLessThanOrEqual(7);
|
||
});
|
||
|
||
it("?q=#weekly&days=60 filters by tag; private items stay out", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=60&q=${encodeURIComponent("#weekly")}`)).text();
|
||
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 35, 42, 49, 56));
|
||
});
|
||
|
||
it("?q= alone searches all time", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?q=fragments`)).text();
|
||
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 35, 42, 49, 56));
|
||
});
|
||
|
||
it("a filter reads only stored items, never diigo; results say whether storage is complete", async () => {
|
||
const { page, pub } = await wireStored("fake-deep-webapp", 1);
|
||
const json = async () =>
|
||
(await call(`${page}?days=300`, { headers: { accept: "application/json" } })).json() as Promise<{
|
||
matched: number;
|
||
complete: boolean;
|
||
}>;
|
||
const before = await testEnv.WEBAPP.calls();
|
||
// page 1 holds 1..100, minus 10 private
|
||
expect(await json()).toMatchObject({ matched: 90, complete: false });
|
||
expect(await (await call(`${page}?days=300`)).text()).toMatch(/some matches may be missing/);
|
||
expect(await testEnv.WEBAPP.calls()).toBe(before);
|
||
await pub.page(2, PAGE_SIZE);
|
||
await pub.page(3, PAGE_SIZE);
|
||
expect(await json()).toMatchObject({ matched: 270, complete: true });
|
||
});
|
||
|
||
it("the page is an h-feed of h-entry bookmarks: microformats are its API for other sites", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=7&by=created`)).text();
|
||
const base = `https://diigo-cache.test${page}`;
|
||
const { items, rels } = mf2(html, { baseUrl: base });
|
||
const feed = items.find((i) => i.type?.includes("h-feed"));
|
||
const entries = feed?.children ?? [];
|
||
expect(entries.map((e) => e.properties.name?.[0])).toEqual(dated(1, 2, 3, 4, 6, 7));
|
||
const seven = entries[5].properties;
|
||
expect(seven["bookmark-of"]).toEqual(["https://dated.example/7"]);
|
||
expect(seven.summary).toEqual(["a week of fragments"]);
|
||
expect(seven.category).toEqual(["ocap", "weekly"]);
|
||
const published = new Date(String(seven.published?.[0])).getTime();
|
||
const updated = new Date(String(seven.updated?.[0])).getTime();
|
||
// 7 isn't a 5: created when updated, 6 days + 1h ago
|
||
expect(published).toBe(updated);
|
||
expect(Date.now() - published).toBeGreaterThan(6 * 24 * 60 * 60 * 1000);
|
||
// one page of matches: no rel=next
|
||
expect(rels.next).toBeUndefined();
|
||
});
|
||
|
||
it("/all lists every stored shared bookmark, none hidden, as h-entries", async () => {
|
||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||
const html = await (await call(`${page}/all`)).text();
|
||
// 300, one a day, minus every 10th (private)
|
||
const want = Array.from({ length: 300 }, (_, k) => k + 1).filter((n) => n % 10 !== 0);
|
||
expect(titlesIn(html)).toEqual(dated(...want));
|
||
expect(html).not.toMatch(/<li[^>]* hidden/);
|
||
const { items } = mf2(html, { baseUrl: `https://diigo-cache.test${page}/all` });
|
||
expect(items.find((i) => i.type?.includes("h-feed"))?.children).toHaveLength(270);
|
||
// without JS, the search form asks the server
|
||
expect(html).toContain(`<form id="search" method="get" action="${page}">`);
|
||
expect(html).toContain('import { shown } from "/_query.js"');
|
||
});
|
||
|
||
it("/_query.js serves the worker's own matcher, as a browser module", async () => {
|
||
const res = await call("/_query.js");
|
||
expect(res.headers.get("content-type")).toMatch(/^text\/javascript/);
|
||
const js = await res.text();
|
||
expect(js).toBe(QUERY_JS);
|
||
expect(js).toMatch(/^export function shown\(/m);
|
||
expect(js).not.toMatch(/^import /m);
|
||
});
|
||
|
||
it("searching /all's microformats client-side finds what the server finds", async () => {
|
||
const page = await wireDated();
|
||
const all = await (await call(`${page}/all`)).text();
|
||
const entries = mf2(all, { baseUrl: `https://diigo-cache.test${page}/all` }).items.find((i) => i.type?.includes("h-feed"))?.children ?? [];
|
||
// as the page's script reads each h-entry
|
||
const secs = (v: unknown) => (v === undefined ? undefined : Date.parse(String(v)) / 1000);
|
||
const items = entries.map(({ properties: p }) => ({
|
||
title: p.name?.[0],
|
||
url: p["bookmark-of"]?.[0],
|
||
description: p.summary?.[0],
|
||
tags: (p.category ?? []).join(","),
|
||
created_at: secs(p.published?.[0]),
|
||
updated_at: secs(p.updated?.[0]),
|
||
}));
|
||
const names = items.map((i) => i.title);
|
||
for (const q of ["fragments", "#ocap -#weekly", "newer_than:7d", "created:2026 dated", '"dated 1"']) {
|
||
const server = titlesIn(await (await call(`${page}?q=${encodeURIComponent(q)}`)).text());
|
||
const result = shown(items, q, new Date());
|
||
if ("error" in result) throw new Error(result.error);
|
||
// the same matches; /all keeps its updated order, where created: sorts by created
|
||
expect(names.filter((_, i) => result.show[i]).sort(), q).toEqual(server.sort());
|
||
expect(server.length, q).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it("filtered pages link the next page with rel=next, keeping the filter", async () => {
|
||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||
const html = await (await call(`${page}?days=300`)).text();
|
||
const { rels } = mf2(html, { baseUrl: `https://diigo-cache.test${page}?days=300` });
|
||
expect(rels.next).toEqual([`https://diigo-cache.test${page}?days=300&page=2`]);
|
||
});
|
||
|
||
it("JSON answers carry the canonical filter, so devs see what was understood", async () => {
|
||
const page = await wireDated();
|
||
const res = await call(`${page}?days=7&q=${encodeURIComponent("#Weekly")}`, { headers: { accept: "application/json" } });
|
||
expect(await res.json()).toMatchObject({
|
||
filter: { by: "updated", tags: [{ text: "weekly", negated: false }] },
|
||
complete: true,
|
||
});
|
||
});
|
||
|
||
it("a malformed filter is a 400 with a message, not an empty list", async () => {
|
||
const page = await wireDated();
|
||
const res = await call(`${page}?days=7x`);
|
||
expect(res.status).toBe(400);
|
||
expect(await res.text()).toMatch(/days/);
|
||
});
|
||
|
||
it("the form offers the day presets, date inputs, and search, echoing the current filter", async () => {
|
||
const page = await wireDated();
|
||
const html = await (await call(`${page}?days=30&q=ocap`)).text();
|
||
expect(html).toMatch(/<select name="days">/);
|
||
expect(html).toMatch(/<select name="by">/);
|
||
for (const n of [7, 30, 90, 365]) expect(html).toContain(`<option value="${n}"`);
|
||
expect(html).toContain('<option value="30" selected>');
|
||
expect(html).toMatch(/<input type="date" name="after"/);
|
||
expect(html).toMatch(/<input type="date" name="before"/);
|
||
expect(html).toMatch(/<input[^>]* name="q"[^>]* value="ocap"/);
|
||
// times are UTC, and the page says so
|
||
expect(html).toMatch(/UTC/);
|
||
});
|
||
});
|