1
14import { afterAll, beforeAll, describe, expect, it } from "vitest";
15import { createTestApp, type TestAppHandle } from "./helpers/api";
16import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
17
18let app: TestAppHandle;
19const json = async (res: Response): Promise<Record<string, unknown>> =>
20 (await res.json().catch(() => ({}))) as Record<string, unknown>;
21
22interface ReviewStateResp {
23 state: string;
24 review: null | { id: string; reviewerIds: string[]; requestedBy: string; decision: string | null };
25}
26
27beforeAll(async () => {
28 app = await createTestApp();
29}, 180_000);
30afterAll(async () => {
31 await app?.close();
32});
33
34describe("T-162/T-163: review workflow", () => {
35 it("legacy page: GET review → {state: published, review: null}; submit without reviewers → 400", async () => {
36 const owner = await createUser(app.request, { name: "Rv Owner" });
37 const h = authHeaders(owner.accessToken!);
38 const ws = await createWorkspace(app.request, h, "Rv WS");
39 const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "Doc" } }))) as { id: string };
40
41
42 const st = (await json(await app.request("GET", `/api/v1/pages/${page.id}/review`, { headers: h }))) as ReviewStateResp;
43 expect(st.state).toBe("published");
44 expect(st.review).toBeNull();
45
46
47 const bad = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit" } });
48 expect(bad.status).toBe(400);
49
50
51 const outsider = await createUser(app.request, { name: "Rv Outsider" });
52 const bad2 = await app.request("POST", `/api/v1/pages/${page.id}/review`, {
53 headers: h,
54 body: { action: "submit", reviewerIds: [outsider.userId] }
55 });
56 expect(bad2.status).toBe(400);
57 });
58
59 it("full lifecycle: submit → approve (requester notified); request_changes → resubmit → approve", async () => {
60 const owner = await createUser(app.request, { name: "FL Editor" });
61 const reviewer = await createUser(app.request, { name: "FL Reviewer" });
62 const h = authHeaders(owner.accessToken!);
63 const hr = authHeaders(reviewer.accessToken!);
64 const ws = await createWorkspace(app.request, h, "FL WS");
65 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, reviewer.email, "MEMBER");
66 const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "FL doc" } }))) as { id: string };
67
68
69 const sub = await app.request("POST", `/api/v1/pages/${page.id}/review`, {
70 headers: h,
71 body: { action: "submit", reviewerIds: [reviewer.userId], comment: "please review" }
72 });
73 expect(sub.status).toBe(201);
74 const subBody = (await json(sub)) as ReviewStateResp;
75 expect(subBody.state).toBe("in_review");
76
77 const reqNotifs = await app.prisma.notification.findMany({ where: { type: "REVIEW_REQUESTED" }, select: { userId: true, title: true } });
78 expect(reqNotifs.some((n) => n.userId === reviewer.userId)).toBe(true);
79
80
81 const illegal = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } });
82 expect(illegal.status).toBe(400);
83
84
85 const rc = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: hr, body: { action: "request_changes", comment: "fix the intro" } });
86 expect(rc.status).toBe(201);
87 expect(((await json(rc)) as ReviewStateResp).state).toBe("changes_requested");
88
89 const decNotifs = await app.prisma.notification.findMany({ where: { type: "APPROVAL_DECISION" }, select: { userId: true } });
90 expect(decNotifs.some((n) => n.userId === owner.userId)).toBe(true);
91 expect(decNotifs.some((n) => n.userId === reviewer.userId)).toBe(false);
92
93 const closed = await app.prisma.review.findFirst({ where: { pageId: page.id, closedAt: { not: null } }, select: { decision: true, decidedBy: true } });
94 expect(closed?.decision).toBe("changes_requested");
95 expect(closed?.decidedBy).toBe(reviewer.userId);
96
97
98 const resub = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } });
99 expect(resub.status).toBe(201);
100
101 const ok = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: hr, body: { action: "approve" } });
102 expect(ok.status).toBe(201);
103 expect(((await json(ok)) as ReviewStateResp).state).toBe("published");
104 const final = await app.prisma.page.findUnique({ where: { id: page.id }, select: { reviewState: true } });
105 expect(final?.reviewState).toBe("published");
106
107 const audits = await app.prisma.auditLog.findMany({ where: { resourceType: "page", resourceId: page.id, action: { startsWith: "review." } }, orderBy: { createdAt: "asc" }, select: { action: true } });
108 expect(audits.map((a) => a.action)).toEqual(["review.submitted", "review.changes_requested", "review.submitted", "review.approved"]);
109 });
110
111 it("A-003 gate: a page-EDIT member who is NOT a reviewer of record → 403 on approve", async () => {
112 const owner = await createUser(app.request, { name: "G Owner" });
113 const reviewer = await createUser(app.request, { name: "G Reviewer" });
114 const bystander = await createUser(app.request, { name: "G Bystander" });
115 const h = authHeaders(owner.accessToken!);
116 const ws = await createWorkspace(app.request, h, "G WS");
117 for (const u of [reviewer, bystander]) await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, u.email, "MEMBER");
118 const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "G doc" } }))) as { id: string };
119 await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } });
120
121
122 const denied = await app.request("POST", `/api/v1/pages/${page.id}/review`, {
123 headers: authHeaders(bystander.accessToken!),
124 body: { action: "approve" }
125 });
126 expect(denied.status).toBe(403);
127 const deniedRc = await app.request("POST", `/api/v1/pages/${page.id}/review`, {
128 headers: authHeaders(bystander.accessToken!),
129 body: { action: "request_changes" }
130 });
131 expect(deniedRc.status).toBe(403);
132
133 const deniedEditor = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "approve" } });
134 expect(deniedEditor.status).toBe(403);
135 });
136
137 it("cancel (editor) withdraws an in-flight review; approve with no open review → 400", async () => {
138 const owner = await createUser(app.request, { name: "C Owner" });
139 const reviewer = await createUser(app.request, { name: "C Reviewer" });
140 const h = authHeaders(owner.accessToken!);
141 const ws = await createWorkspace(app.request, h, "C WS");
142 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, reviewer.email, "MEMBER");
143 const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "C doc" } }))) as { id: string };
144 await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } });
145
146
147 const deniedCancel = await app.request("POST", `/api/v1/pages/${page.id}/review`, {
148 headers: authHeaders(reviewer.accessToken!),
149 body: { action: "cancel" }
150 });
151 expect(deniedCancel.status).toBe(403);
152 const cancel = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "cancel" } });
153 expect(cancel.status).toBe(201);
154 expect(((await json(cancel)) as ReviewStateResp).state).toBe("published");
155
156
157 const none = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "approve" } });
158 expect(none.status).toBe(400);
159 });
160
161 it("non-member → 403; unknown page → 404 (no-leak)", async () => {
162 const owner = await createUser(app.request, { name: "Lk Owner" });
163 const outsider = await createUser(app.request, { name: "Lk Outsider" });
164 const h = authHeaders(owner.accessToken!);
165 const ws = await createWorkspace(app.request, h, "Lk WS");
166 const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "Lk doc" } }))) as { id: string };
167 const denied = await app.request("GET", `/api/v1/pages/${page.id}/review`, { headers: authHeaders(outsider.accessToken!) });
168 expect(denied.status).toBe(403);
169 const missing = await app.request("GET", `/api/v1/pages/does_not_exist/review`, { headers: h });
170 expect(missing.status).toBe(404);
171 const missingPost = await app.request("POST", `/api/v1/pages/does_not_exist/review`, { headers: h, body: { action: "approve" } });
172 expect(missingPost.status).toBe(404);
173 });
174});
175