/** * AI Software Factory — Integration: Pages (Phase 3) * * Page lifecycle: create (root + nested), rename, move (reparenting), * trash/restore/purge, fractional-position invariants, version snapshots, * and server-side authorization (guest denial, cross-tenant denial). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** Create a page via the API; returns its id. */ async function newPage( headers: Record, workspaceId: string, title: string, parentId: string | null = null, ): Promise { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title, parentId }, }); expect(res.status).toBe(201); // D4 repair: the API returns the BARE page (`{ ...page, databaseId, // blockIds }`) — there is no `page` wrapper (page.service.ts `create`). return ((await json(res)) as { id: string }).id; } describe("Pages integration", () => { it("create root page: defaults (position A, version 1) + audit", async () => { const user = await createUser(app.request, { name: "Page Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Pages WS", ); const pageId = await newPage(authHeaders(user.accessToken!), workspaceId, "Root One"); const page = await app.prisma.page.findUnique({ where: { id: pageId } }); expect(page).not.toBeNull(); expect(page?.title).toBe("Root One"); expect(page?.parentId).toBeNull(); // The position scheme is the current LexoRank (capital `A` first), not the // legacy lowercase `a0` — `positions.first()` returns "A" and page create // appends under that grid (recorded: the old assertion predated the // between()/rebalance rework). expect(page?.position).toBe("A"); expect(page?.version).toBe(1); expect(page?.trashedAt).toBeNull(); expect(page?.deletedAt).toBeNull(); const audit = await app.prisma.auditLog.findFirst({ where: { action: "page.created", resourceId: pageId }, }); expect(audit).not.toBeNull(); }); it("create nested page: parent/child + grandparent denormalization", async () => { const user = await createUser(app.request, { name: "Nest Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Nest WS", ); const h = authHeaders(user.accessToken!); const parent = await newPage(h, workspaceId, "Parent"); const child = await newPage(h, workspaceId, "Child", parent); const grandchild = await newPage(h, workspaceId, "Grandchild", child); const c = await app.prisma.page.findUnique({ where: { id: child } }); expect(c?.parentId).toBe(parent); expect(c?.grandparentId).toBeNull(); const gc = await app.prisma.page.findUnique({ where: { id: grandchild } }); expect(gc?.parentId).toBe(child); expect(gc?.grandparentId).toBe(parent); // denormalized for fast sibling queries // Siblings get distinct fractional positions. const sibling = await newPage(h, workspaceId, "Sibling", parent); const s = await app.prisma.page.findUnique({ where: { id: sibling } }); expect(s?.position).not.toBe(c?.position); }); it("move (reparent): updates parentId + grandparentId, position preserved", async () => { const user = await createUser(app.request, { name: "Move Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Move WS", ); const h = authHeaders(user.accessToken!); const src = await newPage(h, workspaceId, "Source"); const dst = await newPage(h, workspaceId, "Dest"); const mover = await newPage(h, workspaceId, "Mover", src); // Reparent under another ROOT → grandparent stays null. const res = await app.request("PATCH", `/api/v1/pages/${mover}`, { headers: h, body: { parentId: dst }, }); expect(res.status).toBe(200); let page = await app.prisma.page.findUnique({ where: { id: mover } }); expect(page?.parentId).toBe(dst); expect(page?.grandparentId).toBeNull(); // Reparent under a NESTED parent (mid under src) → grandparent becomes // the nested parent's own parent (src). This pins the reparent half of // TASK-0035's "grandparentId maintained on create/move". const mid = await newPage(h, workspaceId, "Mid", src); const deep = await newPage(h, workspaceId, "Deep"); const res2 = await app.request("PATCH", `/api/v1/pages/${deep}`, { headers: h, body: { parentId: mid }, }); expect(res2.status).toBe(200); page = await app.prisma.page.findUnique({ where: { id: deep } }); expect(page?.parentId).toBe(mid); expect(page?.grandparentId).toBe(src); }); it("move to self/descendant is rejected (cycle guard)", async () => { const user = await createUser(app.request, { name: "Cycle Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Cycle WS", ); const h = authHeaders(user.accessToken!); const a = await newPage(h, workspaceId, "A"); const b = await newPage(h, workspaceId, "B", a); // A → under B (B is A's child) would create a cycle. const res = await app.request("PATCH", `/api/v1/pages/${a}`, { headers: h, body: { parentId: b }, }); expect([400, 409]).toContain(res.status); // A → under itself. const self = await app.request("PATCH", `/api/v1/pages/${a}`, { headers: h, body: { parentId: a }, }); expect([400, 409]).toContain(self.status); // A is unchanged. const page = await app.prisma.page.findUnique({ where: { id: a } }); expect(page?.parentId).toBeNull(); }); it("trash: trashedAt set, hidden from lists; restore: trashedAt cleared, restoredAt set", async () => { const user = await createUser(app.request, { name: "Trash Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Trash WS", ); const h = authHeaders(user.accessToken!); const pageId = await newPage(h, workspaceId, "Trash Me"); const del = await app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h }); expect([200, 204]).toContain(del.status); let page = await app.prisma.page.findUnique({ where: { id: pageId } }); expect(page?.trashedAt).not.toBeNull(); expect(page?.deletedAt).toBeNull(); // Hidden from the workspace page list. const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, }); const body = await json(list); // D4 repair: the page list is `{ data, nextCursor }` (page.service.ts // `list`) — not `{ pages }`. const pages = (body.data as { id: string }[] | undefined) ?? []; expect(pages.some((p) => p.id === pageId)).toBe(false); // Restore. const restore = await app.request("POST", `/api/v1/pages/${pageId}/restore`, { headers: h }); expect([200, 201]).toContain(restore.status); page = await app.prisma.page.findUnique({ where: { id: pageId } }); expect(page?.trashedAt).toBeNull(); expect(page?.restoredAt).not.toBeNull(); }); it("purge: hard delete (row gone)", async () => { const user = await createUser(app.request, { name: "Purge Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Purge WS", ); const h = authHeaders(user.accessToken!); const pageId = await newPage(h, workspaceId, "Purge Me"); await app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h }); // The page purge contract is `DELETE /pages/:id/permanent` (the web trash // UI's call). NOTE (rule 29): blocks/files use `DELETE /:id?purge=true` — // a cross-resource inconsistency, but the page consumer contract is // `/permanent`; the old `?purge=true` here was silently ignored by the // trash route (it soft-deleted), which is exactly why this test went red. const purge = await app.request("DELETE", `/api/v1/pages/${pageId}/permanent`, { headers: h, }); expect([200, 204]).toContain(purge.status); const gone = await app.prisma.page.findUnique({ where: { id: pageId } }); expect(gone).toBeNull(); }); it("list ?trashed=true: trashed roots only, Admin/Owner-gated, flags mutually exclusive", async () => { const user = await createUser(app.request, { name: "TrashList Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "TrashList WS", ); const h = authHeaders(user.accessToken!); const a = await newPage(h, workspaceId, "Root A"); const b = await newPage(h, workspaceId, "Root B"); const child = await newPage(h, workspaceId, "Child of B", b); // Trash a root + a child (the child's parent stays live, so it is itself // a "trash root" under the service's OR predicate). await app.request("DELETE", `/api/v1/pages/${a}`, { headers: h }); await app.request("DELETE", `/api/v1/pages/${child}`, { headers: h }); const list = await app.request( "GET", `/api/v1/workspaces/${workspaceId}/pages?trashed=true&limit=50`, { headers: h }, ); expect(list.status).toBe(200); const body = await json(list); const ids = ((body.data as { id: string }[] | undefined) ?? []).map((p) => p.id); expect(ids).toContain(a); expect(ids).toContain(child); expect(ids).not.toContain(b); // live pages stay out expect(body).toHaveProperty("nextCursor"); // ?trashed + ?favorites are mutually exclusive (400). const both = await app.request( "GET", `/api/v1/workspaces/${workspaceId}/pages?trashed=true&favorites=true`, { headers: h }, ); expect(both.status).toBe(400); // GUEST cannot list the trash even though they can read the workspace // (docs/api/pages.md §3: trashed listing is Admin/Owner only). const guest = await createUser(app.request, { name: "TrashList Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gh = authHeaders(guest.accessToken!); const guestList = await app.request( "GET", `/api/v1/workspaces/${workspaceId}/pages?trashed=true`, { headers: gh }, ); expect(guestList.status).toBe(403); }); it("list ?favorites=true: caller-scoped, position-ordered, no cross-user leak", async () => { const user = await createUser(app.request, { name: "FavList Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "FavList WS", ); const h = authHeaders(user.accessToken!); const p1 = await newPage(h, workspaceId, "Fav One"); const p2 = await newPage(h, workspaceId, "Fav Two"); const p3 = await newPage(h, workspaceId, "Not Favoured"); // Star p1 + p2. All PageFavorite rows default to position "a0", so the // composite (position, pageId) order is what makes the result // deterministic (the reorder API is not built yet). for (const id of [p1, p2]) { const fav = await app.request("POST", `/api/v1/pages/${id}/favorite`, { headers: h }); expect([200, 201]).toContain(fav.status); } const list = await app.request( "GET", `/api/v1/workspaces/${workspaceId}/pages?favorites=true&limit=50`, { headers: h }, ); expect(list.status).toBe(200); const rows = (await json(list)).data as { id: string; favorite: boolean }[] | undefined; expect(rows).toBeDefined(); expect([...(rows ?? []).map((r) => r.id)].sort()).toEqual([p1, p2].sort()); expect((rows ?? []).every((r) => r.favorite === true)).toBe(true); expect((rows ?? []).map((r) => r.id)).not.toContain(p3); // Another member of the same workspace sees an EMPTY list (PageFavorite // is per user — no leak). const other = await createUser(app.request, { name: "FavList Other" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, other.email, "MEMBER"); const oh = authHeaders(other.accessToken!); const otherBody = await json( await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages?favorites=true`, { headers: oh }), ); expect((otherBody.data as unknown[] | undefined)?.length ?? 0).toBe(0); // A page trashed AFTER being starred still lists (trashedAt set, not // hard-deleted) so the favorites page can badge it "In trash". await app.request("DELETE", `/api/v1/pages/${p1}`, { headers: h }); const relist = await json( await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages?favorites=true`, { headers: h }), ); const trashedRow = ((relist.data as { id: string; trashedAt: string | null }[] | undefined) ?? []).find( (r) => r.id === p1, ); expect(trashedRow).toBeTruthy(); expect(trashedRow?.trashedAt).not.toBeNull(); }); it("version: content-changing update bumps page.version (snapshots are manual/auto only)", async () => { const user = await createUser(app.request, { name: "Version Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Version WS", ); const h = authHeaders(user.accessToken!); const pageId = await newPage(h, workspaceId, "Versioned"); const before = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } }); // Two title updates → two version bumps. (Spec TASK-0043: the optimistic // concurrency counter `Page.version` +1 on every write; a write does NOT // itself create a snapshot — snapshots are manual (`POST /versions`) or // auto every 100 block ops, and "the page's counter is NOT bumped by a // snapshot; snapshots observe". The old assertion expected an implicit // snapshot per update, which the versioning design does not do.) await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: h, body: { title: "V2" }, }); await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: h, body: { title: "V3" }, }); const after = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } }); expect(after.version).toBe(before.version + 2); // No snapshot rows yet (a write is not a snapshot). expect( await app.prisma.pageVersion.count({ where: { pageId } }), ).toBe(0); // A manual snapshot captures the CURRENT state and aligns its version // number with the page's counter (it does not bump it). const snap = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers: h, body: { note: "manual-after-updates" }, }); expect([200, 201]).toContain(snap.status); const rows = await app.prisma.pageVersion.findMany({ where: { pageId }, orderBy: { version: "asc" }, }); expect(rows).toHaveLength(1); expect(rows[0]!.snapshot).toBeTruthy(); // Snapshot versions form their own 1-based monotonic sequence // (max(PageVersion.version)+1), independent of the page's optimistic // concurrency counter (Page.version). NOTE (rule 29): the TASK-0043 spec // example says the snapshot "aligns with Page.version", but that only // holds in the single-step example (each patch → one bump → one snapshot); // the page counter is also bumped by non-snapshot writes, so strict // alignment is impossible in general — the monotonic snapshot sequence is // the correct, E2E-pinned behavior (confluence-versions.spec.ts: [1,2]). expect(rows[0]!.version).toBe(1); // The page's counter is unchanged by the snapshot. expect( (await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } })).version, ).toBe(after.version); }); it("authorization: a GUEST member cannot write (create/trash) pages", async () => { const owner = await createUser(app.request, { name: "AuthZ Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "AuthZ WS", ); const guest = await createUser(app.request, { name: "AuthZ Guest" }); // Add as GUEST via the test seam (TASK-0095 S9 — the public invite flow // hashes the token and emails it, so a test cannot complete it without a // mail server; the seam is service-token guarded and test-only). await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gh = authHeaders(guest.accessToken!); const ownerPage = await newPage(authHeaders(owner.accessToken!), workspaceId, "Owner Page"); // GUEST may READ the page (VIEW) ... const read = await app.request("GET", `/api/v1/pages/${ownerPage}`, { headers: gh }); expect(read.status).toBe(200); // ... but cannot create, edit, or trash. const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: gh, body: { title: "Guest Page" }, }); expect([403]).toContain(create.status); const edit = await app.request("PATCH", `/api/v1/pages/${ownerPage}`, { headers: gh, body: { title: "Guest Edit" }, }); expect(edit.status).toBe(403); const trash = await app.request("DELETE", `/api/v1/pages/${ownerPage}`, { headers: gh }); expect(trash.status).toBe(403); const page = await app.prisma.page.findUnique({ where: { id: ownerPage } }); expect(page?.title).toBe("Owner Page"); expect(page?.trashedAt).toBeNull(); }); // T-180 (Phase 8) — page/content MARKDOWN export. it("export markdown: owner 200 + content + audit; GUEST (VIEW) 200; restricted (NONE) 403; unknown 404", async () => { const owner = await createUser(app.request, { name: "Export Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), `Export WS ${Date.now()}`, ); const h = authHeaders(owner.accessToken!); const pageId = await newPage(h, workspaceId, "Export Doc"); // Blocks via the API (owner is EDIT). HTML-string content = the web // editor's storage shape (the serializer reads both shapes). const mkBlock = (type: string, content: string, afterBlockId?: string) => app.request("POST", `/api/v1/pages/${pageId}/blocks`, { headers: h, body: { type, content, ...(afterBlockId ? { afterBlockId } : {}) }, }); const b1 = await mkBlock("HEADING_1", "

Section

"); expect(b1.status).toBe(201); const firstId = ((await b1.json()) as { id?: string }).id; const b2 = await mkBlock("PARAGRAPH", "

Body text

", firstId); expect(b2.status).toBe(201); // OWNER export → 200, attachment, the rendered markdown, + an audit row. const exp = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, { headers: h }); expect(exp.status).toBe(200); expect(exp.headers.get("content-type")).toContain("text/markdown"); expect(exp.headers.get("content-disposition")).toContain("attachment"); expect(exp.headers.get("content-disposition")).toContain("Export-Doc.md"); const md = await exp.text(); expect(md).toContain("# Export Doc"); expect(md).toContain("# Section"); expect(md).toContain("Body text"); // position order: heading before paragraph expect(md.indexOf("Section")).toBeLessThan(md.indexOf("Body text")); const audit = await app.prisma.auditLog.findFirst({ where: { action: "page.exported", resourceId: pageId }, }); expect(audit).toBeTruthy(); expect((audit?.metadata as Record)?.format).toBe("markdown"); // GUEST (VIEW) may export the page (what they can read, they can export). const guest = await createUser(app.request, { name: "Export Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gExp = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, { headers: authHeaders(guest.accessToken!), }); expect(gExp.status).toBe(200); // A page-level explicit NONE grant denies even a MEMBER (restricted page). const member = await createUser(app.request, { name: "Export Member" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: pageId, level: "NONE", }, }); const denied = await app.request("GET", `/api/v1/pages/${pageId}/export/markdown`, { headers: authHeaders(member.accessToken!), }); expect(denied.status).toBe(403); // Unknown page → 404. const missing = await app.request("GET", `/api/v1/pages/does-not-exist/export/markdown`, { headers: h }); expect(missing.status).toBe(404); }); });