File
Blob: tests/worker/sites/cache.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { |
| 4 | buildSiteHtmlEtag, |
| 5 | buildSiteCacheKey, |
| 6 | buildSiteCacheTags, |
| 7 | createSiteHtmlRevision, |
| 8 | createRenderDependencyHash, |
| 9 | getSitesRendererVersion, |
| 10 | parseSitePmJsonEnvelope, |
| 11 | siteHtmlEtagMatches, |
| 12 | type SitePmJsonEnvelope, |
| 13 | } from "@/worker/sites/cache"; |
| 14 | |
| 15 | const ENVELOPE: SitePmJsonEnvelope = { |
| 16 | content: { type: "doc", content: [{ type: "paragraph" }] }, |
| 17 | metrics: { words: 0, characters: 0 }, |
| 18 | updatedAt: "2026-05-17T12:00:00.000Z", |
| 19 | }; |
| 20 | |
| 21 | describe("Sites cache helpers", () => { |
| 22 | it("drops visitor query params and keys by normalized request path", () => { |
| 23 | const key = buildSiteCacheKey(new Request("https://acme.sites.test/docs?utm_source=newsletter&fbclid=ignored")); |
| 24 | |
| 25 | const url = new URL(key.url); |
| 26 | expect(url.origin).toBe("https://acme.sites.test"); |
| 27 | expect(url.pathname).toBe("/docs"); |
| 28 | expect(url.searchParams.get("utm_source")).toBeNull(); |
| 29 | expect(url.searchParams.get("fbclid")).toBeNull(); |
| 30 | expect(key.method).toBe("GET"); |
| 31 | }); |
| 32 | |
| 33 | it("builds cache tags for later purge support", () => { |
| 34 | expect(buildSiteCacheTags({ workspace_id: "ws1" }, { id: "page1", published_root_id: "root1" })).toBe( |
| 35 | "sites-html,site:ws1,page:page1,root:root1", |
| 36 | ); |
| 37 | }); |
| 38 | |
| 39 | it("uses the configured Worker version metadata binding", () => { |
| 40 | expect( |
| 41 | getSitesRendererVersion({ |
| 42 | CF_VERSION_METADATA: { id: "version-a", tag: "test", timestamp: "2026-05-17T00:00:00.000Z" }, |
| 43 | }), |
| 44 | ).toBe("version-a"); |
| 45 | }); |
| 46 | |
| 47 | it("round-trips a valid envelope through JSON.stringify and parseSitePmJsonEnvelope", () => { |
| 48 | const text = JSON.stringify(ENVELOPE); |
| 49 | expect(parseSitePmJsonEnvelope(text)).toEqual(ENVELOPE); |
| 50 | }); |
| 51 | |
| 52 | it("rejects malformed top-level JSON", () => { |
| 53 | expect(parseSitePmJsonEnvelope("not json")).toBeNull(); |
| 54 | expect(parseSitePmJsonEnvelope("<!doctype html>")).toBeNull(); |
| 55 | }); |
| 56 | |
| 57 | it("rejects envelopes missing required fields", () => { |
| 58 | expect( |
| 59 | parseSitePmJsonEnvelope(JSON.stringify({ metrics: { words: 0, characters: 0 }, updatedAt: "2026-05-17" })), |
| 60 | ).toBeNull(); |
| 61 | expect(parseSitePmJsonEnvelope(JSON.stringify({ content: { type: "doc" }, updatedAt: "2026-05-17" }))).toBeNull(); |
| 62 | expect( |
| 63 | parseSitePmJsonEnvelope(JSON.stringify({ content: { type: "doc" }, metrics: { words: 0, characters: 0 } })), |
| 64 | ).toBeNull(); |
| 65 | expect( |
| 66 | parseSitePmJsonEnvelope( |
| 67 | JSON.stringify({ content: { type: "doc" }, metrics: { words: 0, characters: 0 }, updatedAt: 0 }), |
| 68 | ), |
| 69 | ).toBeNull(); |
| 70 | }); |
| 71 | |
| 72 | it("rejects the old SiteRenderArtifact shape", () => { |
| 73 | expect( |
| 74 | parseSitePmJsonEnvelope( |
| 75 | JSON.stringify({ |
| 76 | version: 1, |
| 77 | bodyHtml: "<p>cached</p>", |
| 78 | outline: [{ id: "intro", text: "Intro", level: 1, href: "#intro" }], |
| 79 | metrics: { words: 2, characters: 12 }, |
| 80 | }), |
| 81 | ), |
| 82 | ).toBeNull(); |
| 83 | }); |
| 84 | |
| 85 | it("validates nested PM JSON content recursively", () => { |
| 86 | const valid = parseSitePmJsonEnvelope( |
| 87 | JSON.stringify({ |
| 88 | content: { |
| 89 | type: "doc", |
| 90 | content: [ |
| 91 | { |
| 92 | type: "paragraph", |
| 93 | content: [{ type: "text", text: "hi" }], |
| 94 | }, |
| 95 | ], |
| 96 | }, |
| 97 | metrics: { words: 1, characters: 2 }, |
| 98 | updatedAt: "2026-05-17", |
| 99 | }), |
| 100 | ); |
| 101 | expect(valid).not.toBeNull(); |
| 102 | }); |
| 103 | }); |
| 104 | |
| 105 | describe("createSiteHtmlRevision", () => { |
| 106 | const base = { |
| 107 | rendererVersion: "worker-version-1", |
| 108 | artifactEtag: '"artifact-1"', |
| 109 | site: { |
| 110 | workspace_id: "ws1", |
| 111 | slug: "acme", |
| 112 | home_page_id: "page1", |
| 113 | updated_at: "2026-05-17T12:00:00.000Z", |
| 114 | workspace_name: "Acme", |
| 115 | workspace_icon: "A", |
| 116 | }, |
| 117 | page: { |
| 118 | id: "page1", |
| 119 | title: "Home", |
| 120 | icon: "H", |
| 121 | cover_url: null, |
| 122 | updated_at: "2026-05-17T12:00:00.000Z", |
| 123 | published_root_id: "page1", |
| 124 | }, |
| 125 | canonicalPath: "/", |
| 126 | currentIsHome: true, |
| 127 | }; |
| 128 | |
| 129 | it("changes for site, page, renderer, canonical, workspace identity, and root inputs", async () => { |
| 130 | const revision = await createSiteHtmlRevision(base); |
| 131 | const changedInputs = [ |
| 132 | { ...base, rendererVersion: "worker-version-2" }, |
| 133 | { ...base, artifactEtag: '"artifact-2"' }, |
| 134 | { ...base, site: { ...base.site, updated_at: "2026-05-17T12:01:00.000Z" } }, |
| 135 | { ...base, site: { ...base.site, workspace_name: "Renamed" } }, |
| 136 | { ...base, site: { ...base.site, workspace_icon: "B" } }, |
| 137 | { ...base, page: { ...base.page, updated_at: "2026-05-17T12:02:00.000Z" } }, |
| 138 | { ...base, page: { ...base.page, title: "Renamed Home" } }, |
| 139 | { ...base, page: { ...base.page, published_root_id: "root2" } }, |
| 140 | { ...base, canonicalPath: "/renamed-page1" }, |
| 141 | ]; |
| 142 | |
| 143 | for (const input of changedInputs) { |
| 144 | await expect(createSiteHtmlRevision(input)).resolves.not.toBe(revision); |
| 145 | } |
| 146 | }); |
| 147 | }); |
| 148 | |
| 149 | describe("siteHtmlEtagMatches", () => { |
| 150 | const etag = buildSiteHtmlEtag("revision-1"); |
| 151 | |
| 152 | it("supports exact, weak, list, and wildcard validators", () => { |
| 153 | expect(siteHtmlEtagMatches(etag, etag)).toBe(true); |
| 154 | expect(siteHtmlEtagMatches(`W/${etag}`, etag)).toBe(true); |
| 155 | expect(siteHtmlEtagMatches(`"other", ${etag}`, etag)).toBe(true); |
| 156 | expect(siteHtmlEtagMatches("*", etag)).toBe(true); |
| 157 | expect(siteHtmlEtagMatches('"other"', etag)).toBe(false); |
| 158 | }); |
| 159 | }); |
| 160 | |
| 161 | describe("createRenderDependencyHash", () => { |
| 162 | it("is stable across object-key order", async () => { |
| 163 | const a = await createRenderDependencyHash({ b: 1, a: 2 }); |
| 164 | const b = await createRenderDependencyHash({ a: 2, b: 1 }); |
| 165 | expect(a).toBe(b); |
| 166 | }); |
| 167 | |
| 168 | it("changes when any render-affecting input changes", async () => { |
| 169 | const base = await createRenderDependencyHash({ site: { name: "A" }, mentions: [] }); |
| 170 | const renamed = await createRenderDependencyHash({ site: { name: "B" }, mentions: [] }); |
| 171 | const withMention = await createRenderDependencyHash({ |
| 172 | site: { name: "A" }, |
| 173 | mentions: [{ pageId: "01ABC", reachable: true }], |
| 174 | }); |
| 175 | expect(renamed).not.toBe(base); |
| 176 | expect(withMention).not.toBe(base); |
| 177 | }); |
| 178 | |
| 179 | it("returns a non-empty base64url digest", async () => { |
| 180 | const hash = await createRenderDependencyHash({ ok: true }); |
| 181 | expect(hash).toMatch(/^[A-Za-z0-9_-]+$/); |
| 182 | expect(hash.length).toBeGreaterThan(20); |
| 183 | }); |
| 184 | }); |