File
Blob: tests/worker/sites/dispatch.workers.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; |
| 3 | import { eq } from "drizzle-orm"; |
| 4 | import * as Y from "yjs"; |
| 5 | |
| 6 | import { pages, workspaceSites, workspaces } from "@/worker/db/d1/schema"; |
| 7 | import { YJS_DOCUMENT_STORE } from "@/shared/constants"; |
| 8 | import { GRADIENT_PRESETS } from "@/shared/page-cover"; |
| 9 | import { resetD1Tables, getDb } from "@tests/worker/helpers/db"; |
| 10 | import { apiRequest } from "@tests/worker/helpers/request"; |
| 11 | import { |
| 12 | expectSitesPageDocumentPreloadLinks, |
| 13 | expectSitesStaticDocumentPreloadLinks, |
| 14 | projectSitePage, |
| 15 | } from "@tests/worker/helpers/sites"; |
| 16 | import { |
| 17 | deletePublishedPage, |
| 18 | seedPage, |
| 19 | seedPublishedPage, |
| 20 | seedUpload, |
| 21 | seedUser, |
| 22 | seedWorkspace, |
| 23 | seedWorkspaceSite, |
| 24 | } from "@tests/worker/helpers/seeds"; |
| 25 | import { seedDocSyncSnapshot, buildYjsDocBytes } from "@tests/worker/helpers/do"; |
| 26 | import { buildSiteCacheKey, buildSiteR2ObjectKey, getSitesCache, writeSiteR2 } from "@/worker/sites/cache"; |
| 27 | import { createSiteCoverHash } from "@/worker/sites/cover"; |
| 28 | import { buildSitePagePath } from "@/worker/lib/site-public-url"; |
| 29 | |
| 30 | const SUBDOMAIN_ORIGIN = "https://acme.sites.test"; |
| 31 | const APEX_ORIGIN = "https://sites.test"; |
| 32 | |
| 33 | async function setSiteDisabled(workspaceId: string): Promise<void> { |
| 34 | await getDb().update(workspaceSites).set({ published_at: null }).where(eq(workspaceSites.workspace_id, workspaceId)); |
| 35 | } |
| 36 | |
| 37 | async 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 | |
| 42 | async 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 | |
| 47 | async function clearRootHtmlCache(): Promise<void> { |
| 48 | await deleteHtmlCache("/"); |
| 49 | } |
| 50 | |
| 51 | async 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 | |
| 61 | function timingCount(response: Response, name: string): number { |
| 62 | return response.headers.get("server-timing")?.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0; |
| 63 | } |
| 64 | |
| 65 | function publicPagePath(title: string, pageId: string): string { |
| 66 | return buildSitePagePath(pageId, title); |
| 67 | } |
| 68 | |
| 69 | function 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 | |
| 94 | function 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 | |
| 109 | describe("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 | |
| 176 | describe("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("· acme"); |
| 245 | expect(html).not.toMatch(/·\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 | }); |