1
8import { afterAll, beforeAll, describe, expect, it } from "vitest";
9import { createTestApp, type TestAppHandle } from "./helpers/api";
10import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
11
12let app: TestAppHandle;
13
14beforeAll(async () => {
15 app = await createTestApp();
16}, 180_000);
17afterAll(async () => {
18 await app?.close();
19});
20
21const json = async (res: Response): Promise<Record<string, unknown>> =>
22 (await res.json().catch(() => ({}))) as Record<string, unknown>;
23
24
25async function newPage(
26 headers: Record<string, string>,
27 workspaceId: string,
28 title: string,
29 parentId: string | null = null,
30): Promise<string> {
31 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
32 headers,
33 body: { title, parentId },
34 });
35 expect(res.status).toBe(201);
36
37
38 return ((await json(res)) as { id: string }).id;
39}
40
41describe("Pages integration", () => {
42 it("create root page: defaults (position A, version 1) + audit", async () => {
43 const user = await createUser(app.request, { name: "Page Owner" });
44 const { workspaceId } = await createWorkspace(
45 app.request,
46 authHeaders(user.accessToken!),
47 "Pages WS",
48 );
49 const pageId = await newPage(authHeaders(user.accessToken!), workspaceId, "Root One");
50
51 const page = await app.prisma.page.findUnique({ where: { id: pageId } });
52 expect(page).not.toBeNull();
53 expect(page?.title).toBe("Root One");
54 expect(page?.parentId).toBeNull();
55
56
57
58
59 expect(page?.position).toBe("A");
60 expect(page?.version).toBe(1);
61 expect(page?.trashedAt).toBeNull();
62 expect(page?.deletedAt).toBeNull();
63
64 const audit = await app.prisma.auditLog.findFirst({
65 where: { action: "page.created", resourceId: pageId },
66 });
67 expect(audit).not.toBeNull();
68 });
69
70 it("create nested page: parent/child + grandparent denormalization", async () => {
71 const user = await createUser(app.request, { name: "Nest Owner" });
72 const { workspaceId } = await createWorkspace(
73 app.request,
74 authHeaders(user.accessToken!),
75 "Nest WS",
76 );
77 const h = authHeaders(user.accessToken!);
78 const parent = await newPage(h, workspaceId, "Parent");
79 const child = await newPage(h, workspaceId, "Child", parent);
80 const grandchild = await newPage(h, workspaceId, "Grandchild", child);
81
82 const c = await app.prisma.page.findUnique({ where: { id: child } });
83 expect(c?.parentId).toBe(parent);
84 expect(c?.grandparentId).toBeNull();
85
86 const gc = await app.prisma.page.findUnique({ where: { id: grandchild } });
87 expect(gc?.parentId).toBe(child);
88 expect(gc?.grandparentId).toBe(parent);
89
90
91 const sibling = await newPage(h, workspaceId, "Sibling", parent);
92 const s = await app.prisma.page.findUnique({ where: { id: sibling } });
93 expect(s?.position).not.toBe(c?.position);
94 });
95
96 it("move (reparent): updates parentId + grandparentId, position preserved", async () => {
97 const user = await createUser(app.request, { name: "Move Owner" });
98 const { workspaceId } = await createWorkspace(
99 app.request,
100 authHeaders(user.accessToken!),
101 "Move WS",
102 );
103 const h = authHeaders(user.accessToken!);
104 const src = await newPage(h, workspaceId, "Source");
105 const dst = await newPage(h, workspaceId, "Dest");
106 const mover = await newPage(h, workspaceId, "Mover", src);
107
108
109 const res = await app.request("PATCH", `/api/v1/pages/${mover}`, {
110 headers: h,
111 body: { parentId: dst },
112 });
113 expect(res.status).toBe(200);
114 let page = await app.prisma.page.findUnique({ where: { id: mover } });
115 expect(page?.parentId).toBe(dst);
116 expect(page?.grandparentId).toBeNull();
117
118
119
120
121 const mid = await newPage(h, workspaceId, "Mid", src);
122 const deep = await newPage(h, workspaceId, "Deep");
123 const res2 = await app.request("PATCH", `/api/v1/pages/${deep}`, {
124 headers: h,
125 body: { parentId: mid },
126 });
127 expect(res2.status).toBe(200);
128 page = await app.prisma.page.findUnique({ where: { id: deep } });
129 expect(page?.parentId).toBe(mid);
130 expect(page?.grandparentId).toBe(src);
131 });
132
133 it("move to self/descendant is rejected (cycle guard)", async () => {
134 const user = await createUser(app.request, { name: "Cycle Owner" });
135 const { workspaceId } = await createWorkspace(
136 app.request,
137 authHeaders(user.accessToken!),
138 "Cycle WS",
139 );
140 const h = authHeaders(user.accessToken!);
141 const a = await newPage(h, workspaceId, "A");
142 const b = await newPage(h, workspaceId, "B", a);
143
144
145 const res = await app.request("PATCH", `/api/v1/pages/${a}`, {
146 headers: h,
147 body: { parentId: b },
148 });
149 expect([400, 409]).toContain(res.status);
150
151
152 const self = await app.request("PATCH", `/api/v1/pages/${a}`, {
153 headers: h,
154 body: { parentId: a },
155 });
156 expect([400, 409]).toContain(self.status);
157
158
159 const page = await app.prisma.page.findUnique({ where: { id: a } });
160 expect(page?.parentId).toBeNull();
161 });
162
163 it("trash: trashedAt set, hidden from lists; restore: trashedAt cleared, restoredAt set", async () => {
164 const user = await createUser(app.request, { name: "Trash Owner" });
165 const { workspaceId } = await createWorkspace(
166 app.request,
167 authHeaders(user.accessToken!),
168 "Trash WS",
169 );
170 const h = authHeaders(user.accessToken!);
171 const pageId = await newPage(h, workspaceId, "Trash Me");
172
173 const del = await app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h });
174 expect([200, 204]).toContain(del.status);
175 let page = await app.prisma.page.findUnique({ where: { id: pageId } });
176 expect(page?.trashedAt).not.toBeNull();
177 expect(page?.deletedAt).toBeNull();
178
179
180 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages`, {
181 headers: h,
182 });
183 const body = await json(list);
184
185
186 const pages = (body.data as { id: string }[] | undefined) ?? [];
187 expect(pages.some((p) => p.id === pageId)).toBe(false);
188
189
190 const restore = await app.request("POST", `/api/v1/pages/${pageId}/restore`, { headers: h });
191 expect([200, 201]).toContain(restore.status);
192 page = await app.prisma.page.findUnique({ where: { id: pageId } });
193 expect(page?.trashedAt).toBeNull();
194 expect(page?.restoredAt).not.toBeNull();
195 });
196
197 it("purge: hard delete (row gone)", async () => {
198 const user = await createUser(app.request, { name: "Purge Owner" });
199 const { workspaceId } = await createWorkspace(
200 app.request,
201 authHeaders(user.accessToken!),
202 "Purge WS",
203 );
204 const h = authHeaders(user.accessToken!);
205 const pageId = await newPage(h, workspaceId, "Purge Me");
206 await app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h });
207
208
209
210
211
212
213 const purge = await app.request("DELETE", `/api/v1/pages/${pageId}/permanent`, {
214 headers: h,
215 });
216 expect([200, 204]).toContain(purge.status);
217 const gone = await app.prisma.page.findUnique({ where: { id: pageId } });
218 expect(gone).toBeNull();
219 });
220
221 it("list ?trashed=true: trashed roots only, Admin/Owner-gated, flags mutually exclusive", async () => {
222 const user = await createUser(app.request, { name: "TrashList Owner" });
223 const { workspaceId } = await createWorkspace(
224 app.request,
225 authHeaders(user.accessToken!),
226 "TrashList WS",
227 );
228 const h = authHeaders(user.accessToken!);
229 const a = await newPage(h, workspaceId, "Root A");
230 const b = await newPage(h, workspaceId, "Root B");
231 const child = await newPage(h, workspaceId, "Child of B", b);
232
233
234 await app.request("DELETE", `/api/v1/pages/${a}`, { headers: h });
235 await app.request("DELETE", `/api/v1/pages/${child}`, { headers: h });
236
237 const list = await app.request(
238 "GET",
239 `/api/v1/workspaces/${workspaceId}/pages?trashed=true&limit=50`,
240 { headers: h },
241 );
242 expect(list.status).toBe(200);
243 const body = await json(list);
244 const ids = ((body.data as { id: string }[] | undefined) ?? []).map((p) => p.id);
245 expect(ids).toContain(a);
246 expect(ids).toContain(child);
247 expect(ids).not.toContain(b);
248 expect(body).toHaveProperty("nextCursor");
249
250
251 const both = await app.request(
252 "GET",
253 `/api/v1/workspaces/${workspaceId}/pages?trashed=true&favorites=true`,
254 { headers: h },
255 );
256 expect(both.status).toBe(400);
257
258
259
260 const guest = await createUser(app.request, { name: "TrashList Guest" });
261 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
262 const gh = authHeaders(guest.accessToken!);
263 const guestList = await app.request(
264 "GET",
265 `/api/v1/workspaces/${workspaceId}/pages?trashed=true`,
266 { headers: gh },
267 );
268 expect(guestList.status).toBe(403);
269 });
270
271 it("list ?favorites=true: caller-scoped, position-ordered, no cross-user leak", async () => {
272 const user = await createUser(app.request, { name: "FavList Owner" });
273 const { workspaceId } = await createWorkspace(
274 app.request,
275 authHeaders(user.accessToken!),
276 "FavList WS",
277 );
278 const h = authHeaders(user.accessToken!);
279 const p1 = await newPage(h, workspaceId, "Fav One");
280 const p2 = await newPage(h, workspaceId, "Fav Two");
281 const p3 = await newPage(h, workspaceId, "Not Favoured");
282
283
284
285
286 for (const id of [p1, p2]) {
287 const fav = await app.request("POST", `/api/v1/pages/${id}/favorite`, { headers: h });
288 expect([200, 201]).toContain(fav.status);
289 }
290
291 const list = await app.request(
292 "GET",
293 `/api/v1/workspaces/${workspaceId}/pages?favorites=true&limit=50`,
294 { headers: h },
295 );
296 expect(list.status).toBe(200);
297 const rows = (await json(list)).data as { id: string; favorite: boolean }[] | undefined;
298 expect(rows).toBeDefined();
299 expect([...(rows ?? []).map((r) => r.id)].sort()).toEqual([p1, p2].sort());
300 expect((rows ?? []).every((r) => r.favorite === true)).toBe(true);
301 expect((rows ?? []).map((r) => r.id)).not.toContain(p3);
302
303
304
305 const other = await createUser(app.request, { name: "FavList Other" });
306 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, other.email, "MEMBER");
307 const oh = authHeaders(other.accessToken!);
308 const otherBody = await json(
309 await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages?favorites=true`, { headers: oh }),
310 );
311 expect((otherBody.data as unknown[] | undefined)?.length ?? 0).toBe(0);
312
313
314
315 await app.request("DELETE", `/api/v1/pages/${p1}`, { headers: h });
316 const relist = await json(
317 await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages?favorites=true`, { headers: h }),
318 );
319 const trashedRow = ((relist.data as { id: string; trashedAt: string | null }[] | undefined) ?? []).find(
320 (r) => r.id === p1,
321 );
322 expect(trashedRow).toBeTruthy();
323 expect(trashedRow?.trashedAt).not.toBeNull();
324 });
325
326 it("version: content-changing update bumps page.version (snapshots are manual/auto only)", async () => {
327 const user = await createUser(app.request, { name: "Version Owner" });
328 const { workspaceId } = await createWorkspace(
329 app.request,
330 authHeaders(user.accessToken!),
331 "Version WS",
332 );
333 const h = authHeaders(user.accessToken!);
334 const pageId = await newPage(h, workspaceId, "Versioned");
335 const before = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } });
336
337
338
339
340
341
342
343 await app.request("PATCH", `/api/v1/pages/${pageId}`, {
344 headers: h,
345 body: { title: "V2" },
346 });
347 await app.request("PATCH", `/api/v1/pages/${pageId}`, {
348 headers: h,
349 body: { title: "V3" },
350 });
351 const after = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } });
352 expect(after.version).toBe(before.version + 2);
353
354
355 expect(
356 await app.prisma.pageVersion.count({ where: { pageId } }),
357 ).toBe(0);
358
359
360
361 const snap = await app.request("POST", `/api/v1/pages/${pageId}/versions`, {
362 headers: h,
363 body: { note: "manual-after-updates" },
364 });
365 expect([200, 201]).toContain(snap.status);
366 const rows = await app.prisma.pageVersion.findMany({
367 where: { pageId },
368 orderBy: { version: "asc" },
369 });
370 expect(rows).toHaveLength(1);
371 expect(rows[0]!.snapshot).toBeTruthy();
372
373
374
375
376
377
378
379
380 expect(rows[0]!.version).toBe(1);
381
382 expect(
383 (await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } })).version,
384 ).toBe(after.version);
385 });
386
387 it("authorization: a GUEST member cannot write (create/trash) pages", async () => {
388 const owner = await createUser(app.request, { name: "AuthZ Owner" });
389 const { workspaceId } = await createWorkspace(
390 app.request,
391 authHeaders(owner.accessToken!),
392 "AuthZ WS",
393 );
394 const guest = await createUser(app.request, { name: "AuthZ Guest" });
395
396
397
398 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
399
400 const gh = authHeaders(guest.accessToken!);
401 const ownerPage = await newPage(authHeaders(owner.accessToken!), workspaceId, "Owner Page");
402
403
404 const read = await app.request("GET", `/api/v1/pages/${ownerPage}`, { headers: gh });
405 expect(read.status).toBe(200);
406
407 const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
408 headers: gh,
409 body: { title: "Guest Page" },
410 });
411 expect([403]).toContain(create.status);
412 const edit = await app.request("PATCH", `/api/v1/pages/${ownerPage}`, {
413 headers: gh,
414 body: { title: "Guest Edit" },
415 });
416 expect(edit.status).toBe(403);
417 const trash = await app.request("DELETE", `/api/v1/pages/${ownerPage}`, { headers: gh });
418 expect(trash.status).toBe(403);
419
420 const page = await app.prisma.page.findUnique({ where: { id: ownerPage } });
421 expect(page?.title).toBe("Owner Page");
422 expect(page?.trashedAt).toBeNull();
423 });
424
425
426 it("export markdown: owner 200 + content + audit; GUEST (VIEW) 200; restricted (NONE) 403; unknown 404", async () => {
427 const owner = await createUser(app.request, { name: "Export Owner" });
428 const { workspaceId } = await createWorkspace(
429 app.request,
430 authHeaders(owner.accessToken!),
431 `Export WS ${Date.now()}`,
432 );
433 const h = authHeaders(owner.accessToken!);
434
435 const pageId = await newPage(h, workspaceId, "Export Doc");
436
437
438 const mkBlock = (type: string, content: string, afterBlockId?: string) =>
439 app.request("POST", `/api/v1/pages/${pageId}/blocks`, {
440 headers: h,
441 body: { type, content, ...(afterBlockId ? { afterBlockId } : {}) },
442 });
443 const b1 = await mkBlock("HEADING_1", "<p>Section</p>");
444 expect(b1.status).toBe(201);
445 const firstId = ((await b1.json()) as { id?: string }).id;
446 const b2 = await mkBlock("PARAGRAPH", "<p>Body text</p>", firstId);
447 expect(b2.status).toBe(201);
448
449
450 const exp = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, { headers: h });
451 expect(exp.status).toBe(200);
452 expect(exp.headers.get("content-type")).toContain("text/markdown");
453 expect(exp.headers.get("content-disposition")).toContain("attachment");
454 expect(exp.headers.get("content-disposition")).toContain("Export-Doc.md");
455 const md = await exp.text();
456 expect(md).toContain("# Export Doc");
457 expect(md).toContain("# Section");
458 expect(md).toContain("Body text");
459
460 expect(md.indexOf("Section")).toBeLessThan(md.indexOf("Body text"));
461
462 const audit = await app.prisma.auditLog.findFirst({
463 where: { action: "page.exported", resourceId: pageId },
464 });
465 expect(audit).toBeTruthy();
466 expect((audit?.metadata as Record<string, unknown>)?.format).toBe("markdown");
467
468
469 const guest = await createUser(app.request, { name: "Export Guest" });
470 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
471 const gExp = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, {
472 headers: authHeaders(guest.accessToken!),
473 });
474 expect(gExp.status).toBe(200);
475
476
477 const member = await createUser(app.request, { name: "Export Member" });
478 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
479 await app.prisma.permission.create({
480 data: {
481 workspaceId,
482 granteeType: "user",
483 granteeId: member.userId,
484 resourceType: "page",
485 resourceId: pageId,
486 level: "NONE",
487 },
488 });
489 const denied = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, {
490 headers: authHeaders(member.accessToken!),
491 });
492 expect(denied.status).toBe(403);
493
494
495 const missing = await app.request("GET", `/api/v1/pages/does-not-exist/export/markdown`, { headers: h });
496 expect(missing.status).toBe(404);
497 });
498});
499