Skip to content
File

Blob: tests/worker/sites/cache.test.ts

typescript185 lines
1import { describe, expect, it } from "vitest";
2 
3import {
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 
15const 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 
21describe("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 
105describe("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 
149describe("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 
161describe("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});