Skip to content
File

Blob: tests/worker/sites/dispatch.workers.test.ts

typescript868 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it } from "vitest";
3import { eq } from "drizzle-orm";
4import * as Y from "yjs";
5 
6import { pages, workspaceSites, workspaces } from "@/worker/db/d1/schema";
7import { YJS_DOCUMENT_STORE } from "@/shared/constants";
8import { GRADIENT_PRESETS } from "@/shared/page-cover";
9import { resetD1Tables, getDb } from "@tests/worker/helpers/db";
10import { apiRequest } from "@tests/worker/helpers/request";
11import {
12 expectSitesPageDocumentPreloadLinks,
13 expectSitesStaticDocumentPreloadLinks,
14 projectSitePage,
15} from "@tests/worker/helpers/sites";
16import {
17 deletePublishedPage,
18 seedPage,
19 seedPublishedPage,
20 seedUpload,
21 seedUser,
22 seedWorkspace,
23 seedWorkspaceSite,
24} from "@tests/worker/helpers/seeds";
25import { seedDocSyncSnapshot, buildYjsDocBytes } from "@tests/worker/helpers/do";
26import { buildSiteCacheKey, buildSiteR2ObjectKey, getSitesCache, writeSiteR2 } from "@/worker/sites/cache";
27import { createSiteCoverHash } from "@/worker/sites/cover";
28import { buildSitePagePath } from "@/worker/lib/site-public-url";
29 
30const SUBDOMAIN_ORIGIN = "https://acme.sites.test";
31const APEX_ORIGIN = "https://sites.test";
32 
33async function setSiteDisabled(workspaceId: string): Promise<void> {
34 await getDb().update(workspaceSites).set({ published_at: null }).where(eq(workspaceSites.workspace_id, workspaceId));
35}
36 
37async function clearSitesHtmlBucket(): Promise<void> {
38 const listing = await env.SITES.list();
39 await Promise.all(listing.objects.map((object) => env.SITES.delete(object.key)));
40}
41 
42async function deleteHtmlCache(path: string, origin = SUBDOMAIN_ORIGIN): Promise<void> {
43 const cache = await getSitesCache();
44 await cache.delete(buildSiteCacheKey(new Request(new URL(path, origin).toString())));
45}
46 
47async function clearRootHtmlCache(): Promise<void> {
48 await deleteHtmlCache("/");
49}
50 
51async function waitForSitesCacheHit(path: string): Promise<Response> {
52 let last: Response | null = null;
53 for (let attempt = 0; attempt < 5; attempt += 1) {
54 last = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
55 if (last.headers.get("server-timing")?.includes('cache_write;desc="skipped_hit"')) return last;
56 await new Promise((resolve) => setTimeout(resolve, 10));
57 }
58 return last ?? apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
59}
60 
61function timingCount(response: Response, name: string): number {
62 return response.headers.get("server-timing")?.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0;
63}
64 
65function publicPagePath(title: string, pageId: string): string {
66 return buildSitePagePath(pageId, title);
67}
68 
69function buildOutlineDocBytes(): Uint8Array {
70 const doc = new Y.Doc();
71 try {
72 const fragment = doc.getXmlFragment(YJS_DOCUMENT_STORE);
73 const first = new Y.XmlElement("heading");
74 first.setAttribute("level", "1");
75 first.setAttribute("bid", "H_INTRO");
76 first.insert(0, [new Y.XmlText("Intro")]);
77 
78 const duplicate = new Y.XmlElement("heading");
79 duplicate.setAttribute("level", "2");
80 duplicate.setAttribute("bid", "H_DUP");
81 duplicate.insert(0, [new Y.XmlText("Intro")]);
82 
83 const empty = new Y.XmlElement("heading");
84 empty.setAttribute("level", "3");
85 empty.setAttribute("bid", "H_EMPTY");
86 
87 fragment.insert(0, [first, duplicate, empty]);
88 return Y.encodeStateAsUpdate(doc);
89 } finally {
90 doc.destroy();
91 }
92}
93 
94function buildMentionDocBytes(pageId: string): Uint8Array {
95 const doc = new Y.Doc();
96 try {
97 const fragment = doc.getXmlFragment(YJS_DOCUMENT_STORE);
98 const paragraph = new Y.XmlElement("paragraph");
99 const mention = new Y.XmlElement("pageMention");
100 mention.setAttribute("pageId", pageId);
101 paragraph.insert(0, [new Y.XmlText("See "), mention]);
102 fragment.insert(0, [paragraph]);
103 return Y.encodeStateAsUpdate(doc);
104 } finally {
105 doc.destroy();
106 }
107}
108 
109describe("Sites host dispatch", () => {
110 beforeEach(async () => {
111 await resetD1Tables();
112 await clearRootHtmlCache();
113 });
114 
115 it("serves the apex placeholder at /", async () => {
116 const res = await apiRequest("/", { origin: APEX_ORIGIN });
117 expect(res.status).toBe(200);
118 expect(res.headers.get("content-type")).toContain("text/html");
119 expectSitesStaticDocumentPreloadLinks(res);
120 const html = await res.text();
121 expect(html).toContain("bland.");
122 expect(html).toContain("bg-[linear-gradient(135deg");
123 expect(html).toContain("text-transparent");
124 });
125 
126 it("404s any non-root path on apex", async () => {
127 const res = await apiRequest("/anything", { origin: APEX_ORIGIN });
128 expect(res.status).toBe(404);
129 });
130 
131 it("serves robots.txt on subdomain hosts", async () => {
132 const res = await apiRequest("/robots.txt", { origin: SUBDOMAIN_ORIGIN });
133 expect(res.status).toBe(200);
134 expect(res.headers.get("content-type")).toContain("text/plain");
135 expect(await res.text()).toContain("Allow: /");
136 });
137 
138 it("rejects POST on a site host", async () => {
139 const res = await apiRequest("/", { method: "POST", body: null, origin: SUBDOMAIN_ORIGIN });
140 expect(res.status).toBe(405);
141 });
142 
143 it("404s a subdomain that has no site row", async () => {
144 const res = await apiRequest("/", { origin: SUBDOMAIN_ORIGIN });
145 expect(res.status).toBe(404);
146 expectSitesStaticDocumentPreloadLinks(res);
147 });
148 
149 it("404s a subdomain whose site row is disabled", async () => {
150 const owner = await seedUser();
151 const ws = await seedWorkspace({ owner_id: owner.id });
152 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
153 await setSiteDisabled(ws.id);
154 
155 const res = await apiRequest("/", { origin: SUBDOMAIN_ORIGIN });
156 expect(res.status).toBe(404);
157 });
158 
159 it("404s the home request when home_page_id is unset", async () => {
160 const owner = await seedUser();
161 const ws = await seedWorkspace({ owner_id: owner.id });
162 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
163 
164 const res = await apiRequest("/", { origin: SUBDOMAIN_ORIGIN });
165 expect(res.status).toBe(404);
166 });
167 
168 it("does not intercept non-site hosts", async () => {
169 // A loopback test origin like 127.0.0.1 lands in the existing SPA shell branch.
170 const res = await apiRequest("/", { origin: "http://127.0.0.1" });
171 expect(res.status).toBeLessThan(500);
172 expect(res.status).not.toBe(405);
173 });
174});
175 
176describe("Sites page resolution", () => {
177 beforeEach(async () => {
178 await resetD1Tables();
179 await clearSitesHtmlBucket();
180 await clearRootHtmlCache();
181 });
182 
183 it("renders a published doc page with title, .tiptap wrapper, stylesheet link, and canonical", async () => {
184 const owner = await seedUser();
185 const ws = await seedWorkspace({ owner_id: owner.id });
186 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Hello Sites" });
187 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
188 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
189 await projectSitePage(env, page.id);
190 
191 const res = await apiRequest(publicPagePath("Hello Sites", page.id), { origin: SUBDOMAIN_ORIGIN });
192 expect(res.status).toBe(200);
193 expect(res.headers.get("content-type")).toContain("text/html");
194 expectSitesPageDocumentPreloadLinks(res);
195 const html = await res.text();
196 expect(html).toMatch(/^<!DOCTYPE html><html/);
197 expect(html).not.toContain("<!doctype html><!DOCTYPE html>");
198 expect(html).toContain("<title>Hello Sites</title>");
199 expect(html).toContain('class="site-shell flex min-h-screen flex-col bg-canvas');
200 expect(html).toContain('class="site-header sticky top-0 z-50');
201 expect(html).toContain('class="site-container mx-auto min-w-0 max-w-3xl"');
202 expect(html).toContain('class="site-footer mt-16 shrink-0');
203 expect(html).toContain('class="tiptap-document-lead"');
204 expect(html).toContain('class="tiptap tiptap-page-body"');
205 expect(html).toContain("mt-4 flex flex-wrap items-center justify-end gap-x-4 gap-y-1");
206 expect(html).toContain('aria-label="Document metrics: 0 words, 0 chars, 0 min read"');
207 expect(html).not.toContain("tiptap-outline");
208 expect(html).not.toContain("sm:pl-7");
209 expect(html).toContain('rel="icon"');
210 expect(html).toContain('href="/icons/favicon.ico"');
211 expect(html).toContain('href="/icons/icon.svg"');
212 expect(html).toContain('href="/icons/favicon-32x32.png"');
213 expect(html).toContain('rel="apple-touch-icon"');
214 expect(html).toContain('name="theme-color" content="#171717"');
215 expect(html).not.toContain('href="data:,"');
216 expect(html).toContain('href="/site-assets/sites-test.css"');
217 expect(html).toContain('rel="preload" as="style" href="/site-assets/fonts-test.css"');
218 expect(html).toContain('rel="stylesheet" href="/site-assets/fonts-test.css"');
219 expect(html).toContain('data-precedence="default"');
220 expect(html).toContain('rel="modulepreload"');
221 expect(html).toContain('href="/site-assets/outline-model-test.js"');
222 expect(html).toContain('src="/site-assets/sites-entry-test.js"');
223 expect(html.indexOf('rel="preload" as="style" href="/site-assets/fonts-test.css"')).toBeLessThan(
224 html.indexOf("<body"),
225 );
226 expect(html.lastIndexOf('rel="stylesheet" href="/site-assets/fonts-test.css"')).toBeGreaterThan(
227 html.indexOf("</main>"),
228 );
229 expect(html.indexOf('src="/site-assets/sites-entry-test.js"')).toBeGreaterThan(
230 html.lastIndexOf('rel="stylesheet" href="/site-assets/fonts-test.css"'),
231 );
232 expect(html).not.toContain("vendor-excalidraw");
233 expect(html).not.toContain("vendor-prosemirror");
234 expect(html).not.toContain("vendor-tiptap");
235 expect(html).not.toContain("vendor-yjs");
236 expect(html).not.toContain("/_sites.css");
237 expect(html).not.toContain("/_sites.js");
238 expect(html).toContain('rel="canonical"');
239 expect(html).toContain(`href="https://acme.sites.test${publicPagePath("Hello Sites", page.id)}"`);
240 expect(html).toContain('property="og:title"');
241 // The full document is composed at request time, so current deploy assets
242 // may appear here; the cached/R2 artifact remains body-only.
243 // Host-neutral: stored shell does not embed the site slug.
244 expect(html).not.toContain("&middot; acme");
245 expect(html).not.toMatch(/&middot;\s*acme/);
246 });
247 
248 it("renders document outline markup through the shared document frame", async () => {
249 const owner = await seedUser();
250 const ws = await seedWorkspace({ owner_id: owner.id });
251 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Outline Page" });
252 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
253 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
254 await seedDocSyncSnapshot(page.id, buildOutlineDocBytes());
255 await projectSitePage(env, page.id);
256 
257 const res = await apiRequest(publicPagePath("Outline Page", page.id), { origin: SUBDOMAIN_ORIGIN });
258 expect(res.status).toBe(200);
259 const html = await res.text();
260 
261 expect(html).toContain('<h1 id="intro" data-bid="H_INTRO">Intro</h1>');
262 expect(html).toContain('<h2 id="intro-2" data-bid="H_DUP">Intro</h2>');
263 expect(html).toContain('<h3 data-bid="H_EMPTY"></h3>');
264 expect(html).toContain('class="tiptap-outline"');
265 expect(html).toContain('class="tiptap-outline tiptap-outline--rail"');
266 expect(html).toContain('data-outline-id="intro"');
267 expect(html).toContain('href="#intro-2"');
268 expect(html).toContain("mx-auto mb-8 w-full min-w-0 max-w-3xl min-[1280px]:hidden");
269 expect(html).toContain("min-[1280px]:grid-cols-[12rem_minmax(0,48rem)_12rem]");
270 expect(html).toContain("min-[1280px]:col-start-2");
271 expect(html).toContain("min-[1280px]:col-start-3");
272 expect(html).toContain("min-[1280px]:pt-[5.5rem]");
273 expect(html.indexOf('class="tiptap-outline"')).toBeLessThan(
274 html.indexOf('<div class="tiptap tiptap-page-body"><h1 id="intro"'),
275 );
276 expect(html).not.toContain("site-header-stage--with-outline");
277 expect(html).not.toContain("site-stage--with-outline");
278 expect(html).not.toContain("site-outline-rail");
279 });
280 
281 it("emits versioned Open Graph image metadata for supported gradient covers", async () => {
282 const owner = await seedUser();
283 const ws = await seedWorkspace({ owner_id: owner.id });
284 const cover = GRADIENT_PRESETS[0];
285 const page = await seedPage({
286 workspace_id: ws.id,
287 created_by: owner.id,
288 title: "Gradient Cover",
289 cover_url: cover,
290 });
291 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
292 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
293 await projectSitePage(env, page.id);
294 
295 const res = await apiRequest(publicPagePath("Gradient Cover", page.id), { origin: SUBDOMAIN_ORIGIN });
296 expect(res.status).toBe(200);
297 const html = await res.text();
298 const coverUrl = `https://acme.sites.test/_assets/${page.id}/cover?v=${await createSiteCoverHash(cover)}`;
299 expect(html).toContain(`property="og:image" content="${coverUrl}"`);
300 expect(html).toContain('property="og:image:type" content="image/png"');
301 expect(html).toContain('property="og:image:width" content="1200"');
302 expect(html).toContain('property="og:image:height" content="630"');
303 });
304 
305 it("emits Open Graph image metadata for safe upload covers only", async () => {
306 const owner = await seedUser();
307 const ws = await seedWorkspace({ owner_id: owner.id });
308 const safePage = await seedPage({
309 workspace_id: ws.id,
310 created_by: owner.id,
311 title: "Upload Cover",
312 cover_url: "/uploads/upload-cover",
313 });
314 await seedUpload({
315 id: "upload-cover",
316 workspace_id: ws.id,
317 uploaded_by: owner.id,
318 page_id: safePage.id,
319 content_type: "image/jpeg",
320 });
321 const unsafePage = await seedPage({
322 workspace_id: ws.id,
323 created_by: owner.id,
324 title: "Unsafe Cover",
325 cover_url: "/uploads/pdf-cover",
326 });
327 await seedUpload({
328 id: "pdf-cover",
329 workspace_id: ws.id,
330 uploaded_by: owner.id,
331 page_id: unsafePage.id,
332 content_type: "application/pdf",
333 });
334 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
335 await seedPublishedPage({ workspace_id: ws.id, page_id: safePage.id, published_by: owner.id });
336 await seedPublishedPage({ workspace_id: ws.id, page_id: unsafePage.id, published_by: owner.id });
337 await projectSitePage(env, safePage.id);
338 await projectSitePage(env, unsafePage.id);
339 
340 const safe = await apiRequest(publicPagePath("Upload Cover", safePage.id), { origin: SUBDOMAIN_ORIGIN });
341 expect(safe.status).toBe(200);
342 const safeHtml = await safe.text();
343 const safeCoverUrl = `https://acme.sites.test/_assets/${safePage.id}/cover?v=${await createSiteCoverHash(
344 "/uploads/upload-cover",
345 )}`;
346 expect(safeHtml).toContain(`property="og:image" content="${safeCoverUrl}"`);
347 expect(safeHtml).toContain('property="og:image:type" content="image/png"');
348 expect(safeHtml).toContain('property="og:image:width" content="1200"');
349 expect(safeHtml).toContain('property="og:image:height" content="630"');
350 
351 const unsafe = await apiRequest(publicPagePath("Unsafe Cover", unsafePage.id), { origin: SUBDOMAIN_ORIGIN });
352 expect(unsafe.status).toBe(200);
353 expect(await unsafe.text()).not.toContain('property="og:image"');
354 });
355 
356 it("renders page icons for accessible page mentions", async () => {
357 const owner = await seedUser();
358 const ws = await seedWorkspace({ owner_id: owner.id });
359 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Mentions" });
360 const target = await seedPage({
361 workspace_id: ws.id,
362 created_by: owner.id,
363 title: "Target Page",
364 icon: "T",
365 });
366 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
367 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
368 await seedPublishedPage({ workspace_id: ws.id, page_id: target.id, published_by: owner.id });
369 await seedDocSyncSnapshot(page.id, buildMentionDocBytes(target.id));
370 await projectSitePage(env, page.id);
371 
372 const res = await apiRequest(publicPagePath("Mentions", page.id), { origin: SUBDOMAIN_ORIGIN });
373 expect(res.status).toBe(200);
374 const html = await res.text();
375 expect(html).toContain(`data-page-id="${target.id}"`);
376 expect(html).toContain('href="/target-page-');
377 expect(html).toContain('<span class="tiptap-page-mention-icon" aria-hidden="true">T</span>');
378 expect(html).toContain('<span class="tiptap-page-mention-label">Target Page</span>');
379 });
380 
381 it("redirects to canonical slug on slug mismatch", async () => {
382 const owner = await seedUser();
383 const ws = await seedWorkspace({ owner_id: owner.id });
384 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Hello Sites" });
385 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
386 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
387 
388 const res = await apiRequest(`/wrong-slug-${page.id}`, {
389 origin: SUBDOMAIN_ORIGIN,
390 redirect: "manual",
391 });
392 expect(res.status).toBe(302);
393 expect(res.headers.get("location")).toContain(publicPagePath("Hello Sites", page.id));
394 });
395 
396 it("redirects legacy uppercase page IDs to lowercase canonical URLs", async () => {
397 const owner = await seedUser();
398 const ws = await seedWorkspace({ owner_id: owner.id });
399 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Hello Sites" });
400 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
401 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
402 
403 const res = await apiRequest(`/hello-sites-${page.id}`, {
404 origin: SUBDOMAIN_ORIGIN,
405 redirect: "manual",
406 });
407 expect(res.status).toBe(302);
408 expect(res.headers.get("location")).toContain(publicPagePath("Hello Sites", page.id));
409 });
410 
411 it("redirects /<slug>-<homeId> to /", async () => {
412 const owner = await seedUser();
413 const ws = await seedWorkspace({ owner_id: owner.id });
414 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Welcome" });
415 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme", home_page_id: page.id });
416 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
417 await projectSitePage(env, page.id);
418 
419 const res = await apiRequest(publicPagePath("Welcome", page.id), {
420 origin: SUBDOMAIN_ORIGIN,
421 redirect: "manual",
422 });
423 expect(res.status).toBe(302);
424 expect(res.headers.get("location")).toMatch(/\/$/);
425 });
426 
427 it("serves home page at /", async () => {
428 const owner = await seedUser();
429 const ws = await seedWorkspace({ owner_id: owner.id });
430 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Welcome" });
431 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme", home_page_id: page.id });
432 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
433 await projectSitePage(env, page.id);
434 
435 const res = await apiRequest("/", { origin: SUBDOMAIN_ORIGIN });
436 expect(res.status).toBe(200);
437 expect(await res.text()).toContain("<title>Welcome</title>");
438 });
439 
440 it("404s an unpublished page", async () => {
441 const owner = await seedUser();
442 const ws = await seedWorkspace({ owner_id: owner.id });
443 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Hidden" });
444 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
445 
446 const res = await apiRequest(publicPagePath("Hidden", page.id), { origin: SUBDOMAIN_ORIGIN });
447 expect(res.status).toBe(404);
448 });
449 
450 it("404s a canvas page even when in publish set", async () => {
451 const owner = await seedUser();
452 const ws = await seedWorkspace({ owner_id: owner.id });
453 const canvas = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Canvas", kind: "canvas" });
454 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
455 await seedPublishedPage({ workspace_id: ws.id, page_id: canvas.id, published_by: owner.id });
456 
457 const res = await apiRequest(publicPagePath("Canvas", canvas.id), { origin: SUBDOMAIN_ORIGIN });
458 expect(res.status).toBe(404);
459 });
460 
461 it("404s an archived page", async () => {
462 const owner = await seedUser();
463 const ws = await seedWorkspace({ owner_id: owner.id });
464 const page = await seedPage({
465 workspace_id: ws.id,
466 created_by: owner.id,
467 title: "Stale",
468 archived_at: new Date().toISOString(),
469 });
470 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
471 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
472 
473 const res = await apiRequest(publicPagePath("Stale", page.id), { origin: SUBDOMAIN_ORIGIN });
474 expect(res.status).toBe(404);
475 });
476 
477 it("404s a published page from another workspace", async () => {
478 const owner = await seedUser();
479 const ws = await seedWorkspace({ owner_id: owner.id, slug: "site-workspace" });
480 const otherWs = await seedWorkspace({ owner_id: owner.id, slug: "other-workspace" });
481 const page = await seedPage({ workspace_id: otherWs.id, created_by: owner.id, title: "Elsewhere" });
482 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
483 await seedPublishedPage({ workspace_id: otherWs.id, page_id: page.id, published_by: owner.id });
484 
485 const res = await apiRequest(publicPagePath("Elsewhere", page.id), { origin: SUBDOMAIN_ORIGIN });
486 expect(res.status).toBe(404);
487 expect(await res.text()).not.toContain("Elsewhere");
488 });
489 
490 it("serves bounded stale HTML after unpublish until the request cache entry expires", async () => {
491 const owner = await seedUser();
492 const ws = await seedWorkspace({ owner_id: owner.id });
493 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Ephemeral" });
494 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
495 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
496 await projectSitePage(env, page.id);
497 
498 const path = publicPagePath("Ephemeral", page.id);
499 const first = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
500 expect(first.status).toBe(200);
501 
502 const cached = await waitForSitesCacheHit(path);
503 expect(cached.status).toBe(200);
504 expectSitesPageDocumentPreloadLinks(cached);
505 expect(cached.headers.get("server-timing")).not.toContain("site_page_lookup");
506 
507 // Option B deliberately allows request-keyed HTML to remain public until
508 // the 300-second internal Cache API TTL expires.
509 await deletePublishedPage(ws.id, page.id);
510 
511 const stale = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
512 expect(stale.status).toBe(200);
513 expect(stale.headers.get("server-timing")).not.toContain("site_page_lookup");
514 expect(await stale.text()).toContain("<title>Ephemeral</title>");
515 
516 await deleteHtmlCache(path);
517 
518 const fresh = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
519 expect(fresh.status).toBe(404);
520 });
521 
522 it("renders inherited subpages under a published ancestor", async () => {
523 const owner = await seedUser();
524 const ws = await seedWorkspace({ owner_id: owner.id });
525 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Parent" });
526 const child = await seedPage({
527 workspace_id: ws.id,
528 created_by: owner.id,
529 parent_id: parent.id,
530 title: "Child",
531 });
532 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
533 await seedPublishedPage({ workspace_id: ws.id, page_id: parent.id, published_by: owner.id });
534 await projectSitePage(env, child.id);
535 
536 const res = await apiRequest(publicPagePath("Child", child.id), { origin: SUBDOMAIN_ORIGIN });
537 expect(res.status).toBe(200);
538 expect(await res.text()).toContain("<title>Child</title>");
539 });
540 
541 it("404s inherited subpages when an archived ancestor breaks the chain", async () => {
542 const owner = await seedUser();
543 const ws = await seedWorkspace({ owner_id: owner.id });
544 const root = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Root" });
545 const archivedParent = await seedPage({
546 workspace_id: ws.id,
547 created_by: owner.id,
548 parent_id: root.id,
549 title: "Archived Parent",
550 archived_at: new Date().toISOString(),
551 });
552 const child = await seedPage({
553 workspace_id: ws.id,
554 created_by: owner.id,
555 parent_id: archivedParent.id,
556 title: "Hidden Child",
557 });
558 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
559 await seedPublishedPage({ workspace_id: ws.id, page_id: root.id, published_by: owner.id });
560 
561 const res = await apiRequest(publicPagePath("Hidden Child", child.id), { origin: SUBDOMAIN_ORIGIN });
562 expect(res.status).toBe(404);
563 expect(await res.text()).not.toContain("Hidden Child");
564 });
565 
566 it("includes seeded Yjs body text in the rendered HTML", async () => {
567 const owner = await seedUser();
568 const ws = await seedWorkspace({ owner_id: owner.id });
569 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Seeded" });
570 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
571 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
572 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Seeded", "First paragraph of body"));
573 await projectSitePage(env, page.id);
574 
575 const res = await apiRequest(publicPagePath("Seeded", page.id), { origin: SUBDOMAIN_ORIGIN });
576 expect(res.status).toBe(200);
577 const html = await res.text();
578 expect(html).toContain("First paragraph of body");
579 expect(html).toContain('name="description" content="First paragraph of body"');
580 expect(html).toContain('property="og:description" content="First paragraph of body"');
581 expect(html).toContain('aria-label="Document metrics: 4 words, 23 chars, 1 min read"');
582 });
583 
584 it("renders from a fresh R2 PM JSON envelope without re-projecting via DocSync", async () => {
585 const owner = await seedUser();
586 const ws = await seedWorkspace({ owner_id: owner.id });
587 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Cached Page" });
588 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
589 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
590 
591 await writeSiteR2(env, ws.id, page.id, {
592 content: {
593 type: "doc",
594 content: [{ type: "paragraph", content: [{ type: "text", text: "R2 CACHED BODY" }] }],
595 },
596 metrics: { words: 3, characters: 14 },
597 updatedAt: page.updated_at,
598 });
599 
600 const res = await apiRequest(publicPagePath("Cached Page", page.id), { origin: SUBDOMAIN_ORIGIN });
601 expect(res.status).toBe(200);
602 const html = await res.text();
603 expect(html).toContain("R2 CACHED BODY");
604 expect(html).toContain('aria-label="Document metrics: 3 words, 14 chars, 1 min read"');
605 });
606 
607 it("returns an uncached unavailable page when R2 PM JSON is missing", async () => {
608 const owner = await seedUser();
609 const ws = await seedWorkspace({ owner_id: owner.id });
610 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Not Ready" });
611 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
612 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
613 
614 const path = publicPagePath("Not Ready", page.id);
615 const res = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
616 expect(res.status).toBe(503);
617 expect(res.headers.get("cache-control")).toBe("no-store");
618 expect(res.headers.get("retry-after")).toBe("5");
619 expect(res.headers.get("etag")).toBeNull();
620 expectSitesStaticDocumentPreloadLinks(res);
621 const html = await res.text();
622 expect(html).toContain("This page is being prepared. Come back in a moment.");
623 expect(html).not.toContain('http-equiv="refresh"');
624 
625 const cache = await getSitesCache();
626 const stored = await cache.match(buildSiteCacheKey(new Request(new URL(path, SUBDOMAIN_ORIGIN).toString())));
627 expect(stored).toBeUndefined();
628 });
629 
630 it("renders a stale R2 PM JSON envelope while self-healing in the background", async () => {
631 const owner = await seedUser();
632 const ws = await seedWorkspace({ owner_id: owner.id });
633 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Stale Projection" });
634 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
635 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
636 
637 await writeSiteR2(env, ws.id, page.id, {
638 content: {
639 type: "doc",
640 content: [{ type: "paragraph", content: [{ type: "text", text: "STALE R2 BODY" }] }],
641 },
642 metrics: { words: 3, characters: 13 },
643 updatedAt: "2026-01-01T00:00:00.000Z",
644 });
645 
646 const res = await apiRequest(publicPagePath("Stale Projection", page.id), { origin: SUBDOMAIN_ORIGIN });
647 expect(res.status).toBe(200);
648 const html = await res.text();
649 expect(html).toContain("STALE R2 BODY");
650 expect(html).toContain('aria-label="Document metrics: 3 words, 13 chars, 1 min read"');
651 });
652 
653 it("changes the HTML ETag when the rendered artifact identity changes", async () => {
654 const owner = await seedUser();
655 const ws = await seedWorkspace({ owner_id: owner.id });
656 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Artifact ETag" });
657 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
658 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
659 
660 const path = publicPagePath("Artifact ETag", page.id);
661 await writeSiteR2(env, ws.id, page.id, {
662 content: {
663 type: "doc",
664 content: [{ type: "paragraph", content: [{ type: "text", text: "OLD BODY" }] }],
665 },
666 metrics: { words: 2, characters: 8 },
667 updatedAt: page.updated_at,
668 });
669 const first = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
670 expect(first.status).toBe(200);
671 const oldEtag = first.headers.get("etag");
672 expect(oldEtag).toMatch(/^"sites-html:/);
673 
674 await waitForSitesCacheHit(path);
675 await deleteHtmlCache(path);
676 const repairedAt = "2030-01-01T00:00:00.000Z";
677 await getDb().update(pages).set({ updated_at: repairedAt }).where(eq(pages.id, page.id));
678 await writeSiteR2(env, ws.id, page.id, {
679 content: {
680 type: "doc",
681 content: [{ type: "paragraph", content: [{ type: "text", text: "REPAIRED BODY" }] }],
682 },
683 metrics: { words: 2, characters: 13 },
684 updatedAt: repairedAt,
685 });
686 
687 const fresh = await apiRequest(path, {
688 origin: SUBDOMAIN_ORIGIN,
689 headers: { "If-None-Match": oldEtag ?? "" },
690 });
691 expect(fresh.status).toBe(200);
692 expect(fresh.headers.get("etag")).not.toBe(oldEtag);
693 expect(await fresh.text()).toContain("REPAIRED BODY");
694 });
695 
696 it("serves repeat HTML from Cache API before reading R2 or rendering", async () => {
697 const owner = await seedUser();
698 const ws = await seedWorkspace({ owner_id: owner.id });
699 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Repeat Cache" });
700 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
701 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
702 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Repeat Cache", "First cached body"));
703 await projectSitePage(env, page.id);
704 
705 const path = publicPagePath("Repeat Cache", page.id);
706 const first = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
707 expect(first.status).toBe(200);
708 expect(timingCount(first, "cache_read")).toBe(1);
709 expect(await first.text()).toContain("First cached body");
710 
711 await writeSiteR2(env, ws.id, page.id, {
712 content: {
713 type: "doc",
714 content: [{ type: "paragraph", content: [{ type: "text", text: "SECOND R2 BODY" }] }],
715 },
716 metrics: { words: 3, characters: 14 },
717 updatedAt: page.updated_at,
718 });
719 
720 const second = await waitForSitesCacheHit(path);
721 expect(second.status).toBe(200);
722 expect(second.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
723 expect(second.headers.get("cache-tag")).toBeNull();
724 expect(second.headers.get("server-timing")).toContain('cache_write;desc="skipped_hit"');
725 expect(second.headers.get("server-timing")).not.toContain("site_page_lookup");
726 expect(second.headers.get("server-timing")).not.toContain("r2_document");
727 expect(second.headers.get("server-timing")).not.toContain("render_stream");
728 const secondHtml = await second.text();
729 expect(secondHtml).toContain("First cached body");
730 expect(secondHtml).not.toContain("SECOND R2 BODY");
731 
732 const cache = await getSitesCache();
733 const stored = await cache.match(buildSiteCacheKey(new Request(new URL(path, SUBDOMAIN_ORIGIN).toString())));
734 expect(stored?.headers.get("cache-control")).toBe("public, max-age=300, must-revalidate");
735 expect(stored?.headers.get("cache-tag")).toBe(`sites-html,site:${ws.id},page:${page.id},root:${page.id}`);
736 });
737 
738 it("returns 304 for a matching Sites HTML ETag before cache or document work", async () => {
739 const owner = await seedUser();
740 const ws = await seedWorkspace({ owner_id: owner.id });
741 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Tagged" });
742 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
743 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
744 await projectSitePage(env, page.id);
745 
746 const path = publicPagePath("Tagged", page.id);
747 const first = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
748 expect(first.status).toBe(200);
749 const etag = first.headers.get("etag");
750 expect(etag).toMatch(/^"sites-html:/);
751 
752 const cached = await waitForSitesCacheHit(path);
753 expect(cached.status).toBe(200);
754 
755 const second = await apiRequest(path, {
756 origin: SUBDOMAIN_ORIGIN,
757 headers: { "If-None-Match": etag ?? "" },
758 });
759 expect(second.status).toBe(304);
760 expect(second.headers.get("etag")).toBe(etag);
761 expect(second.headers.get("last-modified")).toBeTruthy();
762 expect(second.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
763 expect(second.headers.get("content-security-policy")).toContain("default-src 'self'");
764 expect(second.headers.get("server-timing")).toContain('cache_read;desc="skipped_304"');
765 expect(second.headers.get("server-timing")).not.toContain("site_page_lookup");
766 expect(second.headers.get("server-timing")).not.toContain("r2_document");
767 expect(second.headers.get("server-timing")).not.toContain("render_stream");
768 expect(await second.text()).toBe("");
769 });
770 
771 it("renders fresh site chrome around a cached PM JSON envelope", async () => {
772 const owner = await seedUser();
773 const ws = await seedWorkspace({ owner_id: owner.id, name: "Old Workspace" });
774 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Chrome Cache" });
775 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
776 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
777 
778 await writeSiteR2(env, ws.id, page.id, {
779 content: {
780 type: "doc",
781 content: [{ type: "paragraph", content: [{ type: "text", text: "Projected once" }] }],
782 },
783 metrics: { words: 2, characters: 14 },
784 updatedAt: page.updated_at,
785 });
786 
787 await getDb().update(workspaces).set({ name: "Fresh Workspace", icon: "B" }).where(eq(workspaces.id, ws.id));
788 await getDb().update(workspaceSites).set({ home_page_id: page.id }).where(eq(workspaceSites.workspace_id, ws.id));
789 
790 const res = await apiRequest("/", { origin: SUBDOMAIN_ORIGIN });
791 expect(res.status).toBe(200);
792 const html = await res.text();
793 expect(html).toContain("Fresh Workspace");
794 expect(html).toContain("Projected once");
795 expect(html).not.toContain("Old Workspace");
796 expect(html).not.toContain('aria-label="Workspace home"');
797 });
798 
799 it("returns the unavailable page when R2 holds a legacy SiteRenderArtifact shape", async () => {
800 const owner = await seedUser();
801 const ws = await seedWorkspace({ owner_id: owner.id });
802 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Deploy Fresh" });
803 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
804 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
805 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Deploy Fresh", "Fresh body"));
806 
807 await env.SITES.put(
808 buildSiteR2ObjectKey(ws.id, page.id),
809 JSON.stringify({
810 version: 1,
811 bodyHtml: "<p>STALE LEGACY BODY</p>",
812 outline: [],
813 metrics: { words: 1, characters: 5 },
814 }),
815 {
816 httpMetadata: { contentType: "application/json; charset=utf-8" },
817 customMetadata: { updated_at: page.updated_at },
818 },
819 );
820 
821 const res = await apiRequest(publicPagePath("Deploy Fresh", page.id), { origin: SUBDOMAIN_ORIGIN });
822 expect(res.status).toBe(503);
823 expect(res.headers.get("cache-control")).toBe("no-store");
824 expect(res.headers.get("retry-after")).toBe("5");
825 const html = await res.text();
826 expect(html).toContain("<title>Preparing page - ");
827 expect(html).toContain("This page is being prepared. Come back in a moment.");
828 expect(html).not.toContain('http-equiv="refresh"');
829 expect(html).not.toContain("Fresh body");
830 expect(html).not.toContain("STALE LEGACY BODY");
831 });
832 
833 it("serves cached workspace chrome until the request cache entry expires", async () => {
834 const owner = await seedUser();
835 const ws = await seedWorkspace({ owner_id: owner.id, name: "Old Co" });
836 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Renamed" });
837 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
838 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
839 await projectSitePage(env, page.id);
840 
841 const path = publicPagePath("Renamed", page.id);
842 const first = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
843 expect(first.status).toBe(200);
844 const firstHtml = await first.text();
845 expect(firstHtml).toContain("Old Co");
846 
847 const cached = await waitForSitesCacheHit(path);
848 expect(cached.status).toBe(200);
849 
850 await getDb().update(workspaces).set({ name: "New Co" }).where(eq(workspaces.id, ws.id));
851 
852 const stale = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
853 expect(stale.status).toBe(200);
854 expect(stale.headers.get("server-timing")).not.toContain("site_page_lookup");
855 const staleHtml = await stale.text();
856 expect(staleHtml).toContain("Old Co");
857 expect(staleHtml).not.toContain("New Co");
858 
859 await deleteHtmlCache(path);
860 
861 const fresh = await apiRequest(path, { origin: SUBDOMAIN_ORIGIN });
862 expect(fresh.status).toBe(200);
863 const freshHtml = await fresh.text();
864 expect(freshHtml).toContain("New Co");
865 expect(freshHtml).not.toContain("Old Co");
866 });
867});