Skip to content
File

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

typescript826 lines
1import { beforeEach, describe, expect, it } from "vitest";
2import { env } from "cloudflare:workers";
3import { eq } from "drizzle-orm";
4 
5import { GRADIENT_PRESETS } from "@/shared/page-cover";
6import { publishedPages, workspaceSites, workspaces, memberships, pages } from "@/worker/db/d1/schema";
7import { resetD1Tables, getDb } from "@tests/worker/helpers/db";
8import { apiRequest } from "@tests/worker/helpers/request";
9import { ApiErrorResponse } from "@tests/worker/helpers/schemas";
10import { seedMembership, seedPage, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
11import type { SitePageStatus, WorkspaceSiteResponse } from "@/shared/types";
12import { buildSitePagePath } from "@/worker/lib/site-public-url";
13import { readSiteR2, writeSiteR2 } from "@/worker/sites/cache";
14import { recordDocSyncPageSave } from "@/worker/lib/site-invalidation";
15 
16type MutableSitesEnv = { PUBLISHED_SITE_DOMAIN: string };
17 
18let siteBumpCounter = 0;
19 
20function expectedAcmePublicUrl(title: string, pageId: string): string {
21 return `https://acme.sites.test${buildSitePagePath(pageId, title)}`;
22}
23 
24async function setSiteUpdatedAt(workspaceId: string, updatedAt: string): Promise<void> {
25 await getDb()
26 .update(workspaceSites)
27 .set({ updated_at: updatedAt })
28 .where(eq(workspaceSites.workspace_id, workspaceId));
29}
30 
31async function readSiteUpdatedAt(workspaceId: string): Promise<string> {
32 const row = await getDb()
33 .select({ updated_at: workspaceSites.updated_at })
34 .from(workspaceSites)
35 .where(eq(workspaceSites.workspace_id, workspaceId))
36 .get();
37 return row?.updated_at ?? "";
38}
39 
40async function expectSiteBump(workspaceId: string, operation: () => Promise<Response>): Promise<Response> {
41 siteBumpCounter += 1;
42 const old = `2000-01-01T00:00:${siteBumpCounter.toString().padStart(2, "0")}.000Z`;
43 await setSiteUpdatedAt(workspaceId, old);
44 const response = await operation();
45 expect(response.status).toBe(200);
46 expect(await readSiteUpdatedAt(workspaceId)).not.toBe(old);
47 return response;
48}
49 
50describe("Sites management API", () => {
51 beforeEach(async () => {
52 await resetD1Tables();
53 });
54 
55 describe("GET /workspaces/:wid/site", () => {
56 it("returns null site for owner when no row exists", async () => {
57 const owner = await seedUser();
58 const ws = await seedWorkspace({ owner_id: owner.id });
59 
60 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, { userId: owner.id });
61 expect(res.status).toBe(200);
62 const body = (await res.json()) as WorkspaceSiteResponse;
63 expect(body.site).toBeNull();
64 expect(body.base_domain).toBe("sites.test");
65 });
66 
67 it("returns 403 for members (manageSite false)", async () => {
68 const owner = await seedUser();
69 const member = await seedUser();
70 const ws = await seedWorkspace({ owner_id: owner.id });
71 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
72 
73 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, { userId: member.id });
74 expect(res.status).toBe(403);
75 });
76 
77 it("returns 403 for non-members", async () => {
78 const owner = await seedUser();
79 const stranger = await seedUser();
80 const ws = await seedWorkspace({ owner_id: owner.id });
81 
82 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, { userId: stranger.id });
83 expect(res.status).toBe(403);
84 });
85 
86 it("returns sites_disabled when the feature is disabled", async () => {
87 const owner = await seedUser();
88 const ws = await seedWorkspace({ owner_id: owner.id });
89 const originalDomain = env.PUBLISHED_SITE_DOMAIN;
90 
91 (env as MutableSitesEnv).PUBLISHED_SITE_DOMAIN = "";
92 try {
93 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, { userId: owner.id });
94 expect(res.status).toBe(404);
95 const body = ApiErrorResponse.parse(await res.json());
96 expect(body.error).toBe("sites_disabled");
97 } finally {
98 (env as MutableSitesEnv).PUBLISHED_SITE_DOMAIN = originalDomain;
99 }
100 });
101 });
102 
103 describe("PATCH /workspaces/:wid/site", () => {
104 it("lazy-creates a row on first PATCH with a slug", async () => {
105 const owner = await seedUser();
106 const ws = await seedWorkspace({ owner_id: owner.id });
107 
108 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
109 method: "PATCH",
110 body: { slug: "acme-co", published: true },
111 userId: owner.id,
112 });
113 expect(res.status).toBe(200);
114 const body = (await res.json()) as WorkspaceSiteResponse;
115 expect(body.site).toMatchObject({ workspace_id: ws.id, slug: "acme-co" });
116 expect(body.site?.published_at).not.toBeNull();
117 });
118 
119 it("rejects creation without a slug", async () => {
120 const owner = await seedUser();
121 const ws = await seedWorkspace({ owner_id: owner.id });
122 
123 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
124 method: "PATCH",
125 body: { published: true },
126 userId: owner.id,
127 });
128 expect(res.status).toBe(400);
129 const body = ApiErrorResponse.parse(await res.json());
130 expect(body.error).toBe("bad_request");
131 });
132 
133 it("returns 409 when slug collides with another workspace", async () => {
134 const owner = await seedUser();
135 const ws1 = await seedWorkspace({ owner_id: owner.id, slug: "ws-one" });
136 const ws2 = await seedWorkspace({ owner_id: owner.id, slug: "ws-two" });
137 
138 const a = await apiRequest(`/api/v1/workspaces/${ws1.id}/site`, {
139 method: "PATCH",
140 body: { slug: "shared-slug", published: true },
141 userId: owner.id,
142 });
143 expect(a.status).toBe(200);
144 
145 const b = await apiRequest(`/api/v1/workspaces/${ws2.id}/site`, {
146 method: "PATCH",
147 body: { slug: "shared-slug" },
148 userId: owner.id,
149 });
150 expect(b.status).toBe(409);
151 });
152 
153 it("rejects reserved slugs at the validator boundary", async () => {
154 const owner = await seedUser();
155 const ws = await seedWorkspace({ owner_id: owner.id });
156 
157 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
158 method: "PATCH",
159 body: { slug: "www" },
160 userId: owner.id,
161 });
162 expect(res.status).toBe(400);
163 });
164 
165 it("rejects empty-string and short home_page_id values at the schema layer", async () => {
166 const owner = await seedUser();
167 const ws = await seedWorkspace({ owner_id: owner.id });
168 
169 for (const home_page_id of ["", "shortid"]) {
170 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
171 method: "PATCH",
172 body: { slug: "acme", published: true, home_page_id },
173 userId: owner.id,
174 });
175 expect(res.status).toBe(400);
176 }
177 });
178 
179 it("rejects setting a canvas page as home", async () => {
180 const owner = await seedUser();
181 const ws = await seedWorkspace({ owner_id: owner.id });
182 const canvas = await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas" });
183 
184 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
185 method: "PATCH",
186 body: { slug: "acme", published: true },
187 userId: owner.id,
188 });
189 
190 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
191 method: "PATCH",
192 body: { home_page_id: canvas.id },
193 userId: owner.id,
194 });
195 expect(res.status).toBe(400);
196 });
197 
198 it("rejects setting an unpublished page as home", async () => {
199 const owner = await seedUser();
200 const ws = await seedWorkspace({ owner_id: owner.id });
201 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
202 
203 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
204 method: "PATCH",
205 body: { slug: "acme", published: true },
206 userId: owner.id,
207 });
208 
209 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
210 method: "PATCH",
211 body: { home_page_id: page.id },
212 userId: owner.id,
213 });
214 expect(res.status).toBe(400);
215 });
216 
217 it("accepts a published page as home", async () => {
218 const owner = await seedUser();
219 const ws = await seedWorkspace({ owner_id: owner.id });
220 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
221 
222 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
223 method: "PATCH",
224 body: { slug: "acme", published: true },
225 userId: owner.id,
226 });
227 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
228 method: "POST",
229 userId: owner.id,
230 });
231 
232 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
233 method: "PATCH",
234 body: { home_page_id: page.id },
235 userId: owner.id,
236 });
237 expect(res.status).toBe(200);
238 const body = (await res.json()) as WorkspaceSiteResponse;
239 expect(body.site?.home_page_id).toBe(page.id);
240 });
241 
242 it("preserves published_at across enable->enable toggles", async () => {
243 const owner = await seedUser();
244 const ws = await seedWorkspace({ owner_id: owner.id });
245 
246 const first = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
247 method: "PATCH",
248 body: { slug: "acme", published: true },
249 userId: owner.id,
250 });
251 const firstBody = (await first.json()) as WorkspaceSiteResponse;
252 const firstPublishedAt = firstBody.site?.published_at;
253 
254 const second = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
255 method: "PATCH",
256 body: { published: true },
257 userId: owner.id,
258 });
259 const secondBody = (await second.json()) as WorkspaceSiteResponse;
260 expect(secondBody.site?.published_at).toBe(firstPublishedAt);
261 });
262 
263 it("clears published_at when published: false", async () => {
264 const owner = await seedUser();
265 const ws = await seedWorkspace({ owner_id: owner.id });
266 
267 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
268 method: "PATCH",
269 body: { slug: "acme", published: true },
270 userId: owner.id,
271 });
272 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
273 method: "PATCH",
274 body: { published: false },
275 userId: owner.id,
276 });
277 const body = (await res.json()) as WorkspaceSiteResponse;
278 expect(body.site?.published_at).toBeNull();
279 });
280 
281 it("denies members and non-members", async () => {
282 const owner = await seedUser();
283 const member = await seedUser();
284 const stranger = await seedUser();
285 const ws = await seedWorkspace({ owner_id: owner.id });
286 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
287 
288 for (const u of [member, stranger]) {
289 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
290 method: "PATCH",
291 body: { slug: "acme", published: true },
292 userId: u.id,
293 });
294 expect(res.status).toBe(403);
295 }
296 });
297 });
298 
299 describe("GET /workspaces/:wid/site/slug-availability", () => {
300 it("returns false for reserved labels", async () => {
301 const owner = await seedUser();
302 const ws = await seedWorkspace({ owner_id: owner.id });
303 
304 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/slug-availability`, {
305 userId: owner.id,
306 search: { slug: "www" },
307 });
308 expect(res.status).toBe(200);
309 const body = (await res.json()) as { available: boolean; reason?: string };
310 expect(body.available).toBe(false);
311 });
312 
313 it("returns false when slug is taken by another workspace", async () => {
314 const owner = await seedUser();
315 const ws1 = await seedWorkspace({ owner_id: owner.id, slug: "ws-one" });
316 const ws2 = await seedWorkspace({ owner_id: owner.id, slug: "ws-two" });
317 await apiRequest(`/api/v1/workspaces/${ws1.id}/site`, {
318 method: "PATCH",
319 body: { slug: "claimed", published: true },
320 userId: owner.id,
321 });
322 
323 const res = await apiRequest(`/api/v1/workspaces/${ws2.id}/site/slug-availability`, {
324 userId: owner.id,
325 search: { slug: "claimed" },
326 });
327 const body = (await res.json()) as { available: boolean };
328 expect(body.available).toBe(false);
329 });
330 
331 it("treats the same workspace's own slug as available", async () => {
332 const owner = await seedUser();
333 const ws = await seedWorkspace({ owner_id: owner.id });
334 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
335 method: "PATCH",
336 body: { slug: "mine", published: true },
337 userId: owner.id,
338 });
339 
340 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/slug-availability`, {
341 userId: owner.id,
342 search: { slug: "mine" },
343 });
344 const body = (await res.json()) as { available: boolean };
345 expect(body.available).toBe(true);
346 });
347 });
348 
349 describe("POST/DELETE /workspaces/:wid/site/pages/:id", () => {
350 it("publishes a doc page and is idempotent", async () => {
351 const owner = await seedUser();
352 const ws = await seedWorkspace({ owner_id: owner.id });
353 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
354 
355 const first = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
356 method: "POST",
357 userId: owner.id,
358 });
359 expect(first.status).toBe(200);
360 
361 const second = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
362 method: "POST",
363 userId: owner.id,
364 });
365 expect(second.status).toBe(200);
366 
367 const rows = await getDb().select().from(publishedPages).where(eq(publishedPages.page_id, page.id));
368 expect(rows).toHaveLength(1);
369 });
370 
371 it("rejects publishing a canvas page", async () => {
372 const owner = await seedUser();
373 const ws = await seedWorkspace({ owner_id: owner.id });
374 const canvas = await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas" });
375 
376 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${canvas.id}`, {
377 method: "POST",
378 userId: owner.id,
379 });
380 expect(res.status).toBe(400);
381 });
382 
383 it("404s a missing page", async () => {
384 const owner = await seedUser();
385 const ws = await seedWorkspace({ owner_id: owner.id });
386 
387 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/01missing0000000000000000`, {
388 method: "POST",
389 userId: owner.id,
390 });
391 expect(res.status).toBe(404);
392 });
393 
394 it("DELETE removes the explicit root", async () => {
395 const owner = await seedUser();
396 const ws = await seedWorkspace({ owner_id: owner.id });
397 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
398 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
399 method: "POST",
400 userId: owner.id,
401 });
402 const del = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
403 method: "DELETE",
404 userId: owner.id,
405 });
406 expect(del.status).toBe(200);
407 
408 const rows = await getDb().select().from(publishedPages).where(eq(publishedPages.page_id, page.id));
409 expect(rows).toHaveLength(0);
410 });
411 
412 it("DELETE leaves the page's private R2 projection artifact", async () => {
413 const owner = await seedUser();
414 const ws = await seedWorkspace({ owner_id: owner.id });
415 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
416 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
417 method: "POST",
418 userId: owner.id,
419 });
420 
421 const updatedAt = "page-cache-v1";
422 await writeSiteR2(env, ws.id, page.id, {
423 content: { type: "doc", content: [] },
424 metrics: { words: 0, characters: 0 },
425 updatedAt,
426 });
427 expect(await readSiteR2(env, ws.id, page.id, updatedAt)).not.toBeNull();
428 
429 const del = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
430 method: "DELETE",
431 userId: owner.id,
432 });
433 expect(del.status).toBe(200);
434 expect(await readSiteR2(env, ws.id, page.id, updatedAt)).not.toBeNull();
435 });
436 
437 it("bumps the workspace site revision from route-owned public HTML mutations", async () => {
438 const owner = await seedUser();
439 const ws = await seedWorkspace({ owner_id: owner.id });
440 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
441 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Parent" });
442 
443 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
444 method: "PATCH",
445 body: { slug: "acme", published: true },
446 userId: owner.id,
447 });
448 
449 await expectSiteBump(ws.id, () =>
450 apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, { method: "POST", userId: owner.id }),
451 );
452 
453 await expectSiteBump(ws.id, () =>
454 apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, { method: "DELETE", userId: owner.id }),
455 );
456 
457 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, { method: "POST", userId: owner.id });
458 
459 await expectSiteBump(ws.id, () =>
460 apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
461 method: "PATCH",
462 body: { home_page_id: page.id },
463 userId: owner.id,
464 }),
465 );
466 
467 await expectSiteBump(ws.id, () =>
468 apiRequest(`/api/v1/workspaces/${ws.id}`, {
469 method: "PATCH",
470 body: { name: "Renamed Workspace", icon: "R" },
471 userId: owner.id,
472 }),
473 );
474 
475 await expectSiteBump(ws.id, () =>
476 apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}`, {
477 method: "PATCH",
478 body: { icon: "P" },
479 userId: owner.id,
480 }),
481 );
482 
483 await expectSiteBump(ws.id, () =>
484 apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}`, {
485 method: "PATCH",
486 body: { cover_url: GRADIENT_PRESETS[0] },
487 userId: owner.id,
488 }),
489 );
490 
491 await expectSiteBump(ws.id, () =>
492 apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}`, {
493 method: "PATCH",
494 body: { parent_id: parent.id },
495 userId: owner.id,
496 }),
497 );
498 
499 await expectSiteBump(ws.id, () =>
500 apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}`, { method: "DELETE", userId: owner.id }),
501 );
502 });
503 
504 it("records DocSync saves while bumping the site revision only on title changes", async () => {
505 const owner = await seedUser();
506 const ws = await seedWorkspace({ owner_id: owner.id });
507 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Old Title" });
508 
509 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
510 method: "PATCH",
511 body: { slug: "acme", published: true },
512 userId: owner.id,
513 });
514 
515 await setSiteUpdatedAt(ws.id, "2000-01-01T00:00:00.000Z");
516 const beforeUnchangedTitleSync = await getDb().select().from(pages).where(eq(pages.id, page.id)).get();
517 
518 await recordDocSyncPageSave(getDb(), page.id, "Old Title", "2026-05-17T01:00:00.000Z");
519 expect(await readSiteUpdatedAt(ws.id)).toBe("2000-01-01T00:00:00.000Z");
520 let stored = await getDb().select().from(pages).where(eq(pages.id, page.id)).get();
521 expect(stored?.updated_at).not.toBe(beforeUnchangedTitleSync?.updated_at);
522 expect(stored?.updated_at).toBe("2026-05-17T01:00:00.000Z");
523 
524 await recordDocSyncPageSave(getDb(), page.id, "New Title", "2026-05-17T01:01:00.000Z");
525 expect(await readSiteUpdatedAt(ws.id)).toBe("2026-05-17T01:01:00.000Z");
526 stored = await getDb().select().from(pages).where(eq(pages.id, page.id)).get();
527 expect(stored?.title).toBe("New Title");
528 expect(stored?.updated_at).toBe("2026-05-17T01:01:00.000Z");
529 });
530 
531 it("does not touch a foreign workspace's page when DELETE is called with that page's id", async () => {
532 // Defends the tenant boundary: a workspace-A admin must not be able to
533 // bump pages.updated_at on a workspace-B page by supplying B's page id.
534 const adminA = await seedUser();
535 const ownerB = await seedUser();
536 const wsA = await seedWorkspace({ owner_id: adminA.id, slug: "ws-a" });
537 const wsB = await seedWorkspace({ owner_id: ownerB.id, slug: "ws-b" });
538 const pageB = await seedPage({ workspace_id: wsB.id, created_by: ownerB.id });
539 // Make pageB an explicit root in workspace B so we can detect any
540 // accidental cross-workspace publish_pages delete too.
541 await apiRequest(`/api/v1/workspaces/${wsB.id}/site/pages/${pageB.id}`, {
542 method: "POST",
543 userId: ownerB.id,
544 });
545 
546 const beforePage = await getDb().select().from(pages).where(eq(pages.id, pageB.id)).get();
547 const beforeRoots = await getDb().select().from(publishedPages).where(eq(publishedPages.workspace_id, wsB.id));
548 
549 const res = await apiRequest(`/api/v1/workspaces/${wsA.id}/site/pages/${pageB.id}`, {
550 method: "DELETE",
551 userId: adminA.id,
552 });
553 expect(res.status).toBe(200);
554 
555 const afterPage = await getDb().select().from(pages).where(eq(pages.id, pageB.id)).get();
556 const afterRoots = await getDb().select().from(publishedPages).where(eq(publishedPages.workspace_id, wsB.id));
557 expect(afterPage?.updated_at).toBe(beforePage?.updated_at);
558 expect(afterRoots).toHaveLength(beforeRoots.length);
559 });
560 
561 it("denies members", async () => {
562 const owner = await seedUser();
563 const member = await seedUser();
564 const ws = await seedWorkspace({ owner_id: owner.id });
565 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
566 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
567 
568 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
569 method: "POST",
570 userId: member.id,
571 });
572 expect(res.status).toBe(403);
573 });
574 });
575 
576 describe("GET /workspaces/:wid/site/pages", () => {
577 it("returns explicit roots for owners with title/icon metadata", async () => {
578 const owner = await seedUser();
579 const ws = await seedWorkspace({ owner_id: owner.id });
580 const page = await seedPage({
581 workspace_id: ws.id,
582 created_by: owner.id,
583 title: "Roots",
584 icon: "R",
585 });
586 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
587 method: "POST",
588 userId: owner.id,
589 });
590 
591 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages`, { userId: owner.id });
592 expect(res.status).toBe(200);
593 const body = (await res.json()) as {
594 published_roots: { page_id: string; title: string; icon: string | null; kind: "doc" }[];
595 };
596 expect(body.published_roots).toHaveLength(1);
597 expect(body.published_roots[0]).toMatchObject({ page_id: page.id, title: "Roots", icon: "R" });
598 });
599 
600 it("excludes archived roots", async () => {
601 const owner = await seedUser();
602 const ws = await seedWorkspace({ owner_id: owner.id });
603 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
604 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
605 method: "POST",
606 userId: owner.id,
607 });
608 await getDb().update(pages).set({ archived_at: new Date().toISOString() }).where(eq(pages.id, page.id));
609 
610 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages`, { userId: owner.id });
611 const body = (await res.json()) as { published_roots: unknown[] };
612 expect(body.published_roots).toHaveLength(0);
613 });
614 
615 it("denies members", async () => {
616 const owner = await seedUser();
617 const member = await seedUser();
618 const ws = await seedWorkspace({ owner_id: owner.id });
619 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
620 
621 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages`, { userId: member.id });
622 expect(res.status).toBe(403);
623 });
624 });
625 
626 describe("GET /workspaces/:wid/site/pages/:id/status", () => {
627 it("returns direct-publish status with a public URL", async () => {
628 const owner = await seedUser();
629 const ws = await seedWorkspace({ owner_id: owner.id });
630 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Hello World" });
631 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
632 method: "PATCH",
633 body: { slug: "acme", published: true },
634 userId: owner.id,
635 });
636 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
637 method: "POST",
638 userId: owner.id,
639 });
640 
641 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}/status`, {
642 userId: owner.id,
643 });
644 expect(res.status).toBe(200);
645 const body = (await res.json()) as SitePageStatus;
646 expect(body.published).toBe(true);
647 expect(body.is_explicit_root).toBe(true);
648 expect(body.inherited_from).toBeNull();
649 expect(body.public_url).toBe(expectedAcmePublicUrl("Hello World", page.id));
650 expect(body.canvas).toBe(false);
651 });
652 
653 it("returns inherited status for a child of an explicit root", async () => {
654 const owner = await seedUser();
655 const ws = await seedWorkspace({ owner_id: owner.id });
656 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Parent", icon: "P" });
657 const child = await seedPage({
658 workspace_id: ws.id,
659 created_by: owner.id,
660 parent_id: parent.id,
661 title: "Child",
662 });
663 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
664 method: "PATCH",
665 body: { slug: "acme", published: true },
666 userId: owner.id,
667 });
668 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${parent.id}`, {
669 method: "POST",
670 userId: owner.id,
671 });
672 
673 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${child.id}/status`, {
674 userId: owner.id,
675 });
676 const body = (await res.json()) as SitePageStatus;
677 expect(body.is_explicit_root).toBe(false);
678 expect(body.inherited_from).toEqual({ id: parent.id, title: "Parent", icon: "P" });
679 expect(body.published).toBe(true);
680 });
681 
682 it("returns not-published for an isolated page", async () => {
683 const owner = await seedUser();
684 const ws = await seedWorkspace({ owner_id: owner.id });
685 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
686 
687 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}/status`, {
688 userId: owner.id,
689 });
690 const body = (await res.json()) as SitePageStatus;
691 expect(body.published).toBe(false);
692 expect(body.is_explicit_root).toBe(false);
693 expect(body.inherited_from).toBeNull();
694 expect(body.public_url).toBeNull();
695 });
696 
697 it("marks canvas pages so the UI can hide publish controls", async () => {
698 const owner = await seedUser();
699 const ws = await seedWorkspace({ owner_id: owner.id });
700 const canvas = await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas" });
701 
702 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${canvas.id}/status`, {
703 userId: owner.id,
704 });
705 const body = (await res.json()) as SitePageStatus;
706 expect(body.canvas).toBe(true);
707 });
708 
709 it("treats an archived intermediate ancestor as breaking inheritance", async () => {
710 const owner = await seedUser();
711 const ws = await seedWorkspace({ owner_id: owner.id });
712 const root = await seedPage({ workspace_id: ws.id, created_by: owner.id });
713 const middle = await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: root.id });
714 const leaf = await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: middle.id });
715 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
716 method: "PATCH",
717 body: { slug: "acme", published: true },
718 userId: owner.id,
719 });
720 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${root.id}`, {
721 method: "POST",
722 userId: owner.id,
723 });
724 // Archive the middle ancestor. Inheritance must break at the gap.
725 await getDb().update(pages).set({ archived_at: new Date().toISOString() }).where(eq(pages.id, middle.id));
726 
727 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${leaf.id}/status`, {
728 userId: owner.id,
729 });
730 const body = (await res.json()) as SitePageStatus;
731 expect(body.published).toBe(false);
732 expect(body.inherited_from).toBeNull();
733 });
734 
735 it("allows canonical members to read status", async () => {
736 const owner = await seedUser();
737 const member = await seedUser();
738 const ws = await seedWorkspace({ owner_id: owner.id });
739 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
740 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
741 
742 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}/status`, {
743 userId: member.id,
744 });
745 expect(res.status).toBe(200);
746 });
747 
748 it("exposes the public URL on the status response for a member viewing a published page", async () => {
749 const owner = await seedUser();
750 const member = await seedUser();
751 const ws = await seedWorkspace({ owner_id: owner.id });
752 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
753 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Help Center" });
754 
755 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
756 method: "PATCH",
757 body: { slug: "acme", published: true },
758 userId: owner.id,
759 });
760 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, { method: "POST", userId: owner.id });
761 
762 // Confirms the member-accessible /status endpoint is enough on its own
763 // to render the read-only Publish tab -- they never need to hit the
764 // admin-only GET /site endpoint, and the public URL is built server-side.
765 const adminOnly = await apiRequest(`/api/v1/workspaces/${ws.id}/site`, { userId: member.id });
766 expect(adminOnly.status).toBe(403);
767 
768 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}/status`, { userId: member.id });
769 expect(res.status).toBe(200);
770 const body = (await res.json()) as SitePageStatus;
771 expect(body.published).toBe(true);
772 expect(body.is_explicit_root).toBe(true);
773 expect(body.public_url).toBe(expectedAcmePublicUrl("Help Center", page.id));
774 });
775 
776 it("denies guests and non-members", async () => {
777 const owner = await seedUser();
778 const guest = await seedUser();
779 const stranger = await seedUser();
780 const ws = await seedWorkspace({ owner_id: owner.id });
781 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
782 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
783 
784 for (const u of [guest, stranger]) {
785 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}/status`, {
786 userId: u.id,
787 });
788 expect(res.status).toBe(403);
789 }
790 });
791 });
792 
793 describe("workspace deletion cleans up site rows", () => {
794 it("clears workspace_sites and published_pages alongside the workspace", async () => {
795 const owner = await seedUser();
796 const ws = await seedWorkspace({ owner_id: owner.id });
797 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
798 await apiRequest(`/api/v1/workspaces/${ws.id}/site`, {
799 method: "PATCH",
800 body: { slug: "acme", published: true },
801 userId: owner.id,
802 });
803 await apiRequest(`/api/v1/workspaces/${ws.id}/site/pages/${page.id}`, {
804 method: "POST",
805 userId: owner.id,
806 });
807 
808 const del = await apiRequest(`/api/v1/workspaces/${ws.id}`, {
809 method: "DELETE",
810 userId: owner.id,
811 });
812 expect(del.status).toBe(200);
813 
814 const db = getDb();
815 const remainingSite = await db.select().from(workspaceSites).where(eq(workspaceSites.workspace_id, ws.id));
816 expect(remainingSite).toHaveLength(0);
817 const remainingPublished = await db.select().from(publishedPages).where(eq(publishedPages.workspace_id, ws.id));
818 expect(remainingPublished).toHaveLength(0);
819 const remainingWs = await db.select().from(workspaces).where(eq(workspaces.id, ws.id));
820 expect(remainingWs).toHaveLength(0);
821 const remainingMemberships = await db.select().from(memberships).where(eq(memberships.workspace_id, ws.id));
822 expect(remainingMemberships).toHaveLength(0);
823 });
824 });
825});