1
20import { afterAll, beforeAll, describe, expect, it } from "vitest";
21import { createTestApp, type TestAppHandle } from "./helpers/api";
22import {
23 addMemberViaSeam,
24 authHeaders,
25 countAuditEvents,
26 createWorkspace,
27 createUser
28} from "./helpers/seed";
29
30let app: TestAppHandle;
31
32beforeAll(async () => {
33 app = await createTestApp();
34}, 180_000);
35afterAll(async () => {
36 await app?.close();
37});
38
39const json = async (res: Response): Promise<Record<string, unknown>> =>
40 (await res.json().catch(() => ({}))) as Record<string, unknown>;
41
42interface Setup {
43 headers: Record<string, string>;
44 workspaceId: string;
45 pageId: string;
46}
47
48
49async function setupBoard(): Promise<Setup> {
50 const user = await createUser(app.request, { name: "Board Owner" });
51 const headers = authHeaders(user.accessToken!);
52 const { workspaceId } = await createWorkspace(app.request, headers, "Board WS");
53 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
54 headers,
55 body: { title: "My Board", type: "whiteboard" },
56 });
57 expect([200, 201]).toContain(res.status);
58 const page = (await json(res)) as { id: string; kind?: string };
59 expect(page.kind).toBe("whiteboard");
60 return { headers, workspaceId, pageId: page.id };
61}
62
63
64function richCanvas() {
65 return {
66 version: 1,
67 elements: {
68 s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hello", color: "yellow" },
69 t1: { id: "t1", type: "text", x: 0, y: 0, w: 60, h: 20, z: 2, text: "label", fontSize: 14 },
70 r1: { id: "r1", type: "rect", x: 0, y: 0, w: 30, h: 40, z: 3, fill: "blue", stroke: null },
71 e1: { id: "e1", type: "ellipse", x: 0, y: 0, w: 30, h: 30, z: 4, fill: null, stroke: "pink" },
72 f1: { id: "f1", type: "freehand", x: 0, y: 0, w: 50, h: 50, z: 5, points: [0, 0, 5, 5, 10, 2], strokeWidth: 3 },
73 c1: {
74 id: "c1",
75 type: "connector",
76 x: 0,
77 y: 0,
78 w: 0,
79 h: 0,
80 z: 6,
81 from: { elementId: "s1", anchor: "right" },
82 to: { elementId: "r1", anchor: "left" },
83 kind: "orthogonal",
84 arrow: true
85 },
86 g1: { id: "g1", type: "group", x: 0, y: 0, w: 100, h: 80, z: 7, children: ["s1", "t1"] }
87 }
88 };
89}
90
91describe("T-167 whiteboard — create (page kind)", () => {
92 it("POST /workspaces/:id/pages type=whiteboard → page.kind=whiteboard + empty canvas sidecar", async () => {
93 const { headers, pageId } = await setupBoard();
94 const res = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers });
95 expect(res.status).toBe(200);
96 const body = (await json(res)) as {
97 pageId: string;
98 schemaVersion: number;
99 valid: boolean;
100 canvas: { version: number; elements: Record<string, unknown> };
101 };
102 expect(body.pageId).toBe(pageId);
103 expect(body.schemaVersion).toBe(1);
104 expect(body.valid).toBe(true);
105 expect(body.canvas.version).toBe(1);
106 expect(Object.keys(body.canvas.elements)).toHaveLength(0);
107 });
108
109 it("a legacy page create does NOT get a sidecar (kind stays page, no whiteboard row)", async () => {
110 const { headers, workspaceId } = await setupBoard();
111 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
112 headers,
113 body: { title: "normal" }
114 });
115 expect([200, 201]).toContain(res.status);
116 const page = (await json(res)) as { id: string; kind: string };
117 expect(page.kind).toBe("page");
118
119 expect(await app.prisma.whiteboard.findUnique({ where: { pageId: page.id } })).toBeNull();
120 });
121});
122
123describe("T-167 whiteboard — save (PUT) + read (GET)", () => {
124 it("PUT persists a valid canvas (all element types + connector + group); GET round-trips", async () => {
125 const { headers, pageId } = await setupBoard();
126 const put = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
127 headers,
128 body: richCanvas()
129 });
130 expect(put.status).toBe(200);
131 const saved = (await json(put)) as {
132 schemaVersion: number;
133 canvas: { elements: Record<string, unknown> };
134 };
135 expect(saved.schemaVersion).toBe(1);
136 expect(Object.keys(saved.canvas.elements)).toHaveLength(7);
137
138 const get = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers });
139 expect(get.status).toBe(200);
140 const body = (await json(get)) as { valid: boolean; canvas: { elements: Record<string, unknown> } };
141 expect(body.valid).toBe(true);
142 expect(body.canvas.elements.c1).toMatchObject({ type: "connector", arrow: true });
143 expect(body.canvas.elements.g1).toMatchObject({ type: "group", children: ["s1", "t1"] });
144
145
146 expect(await countAuditEvents(app.prisma, { action: "page.whiteboard_updated" })).toBeGreaterThanOrEqual(1);
147 });
148
149 it("unknown canvas version → 400 VALIDATION_ERROR (renderer read-only contract)", async () => {
150 const { headers, pageId } = await setupBoard();
151 const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
152 headers,
153 body: { version: 99, elements: {} }
154 });
155 expect(res.status).toBe(400);
156 const body = await json(res);
157 expect((body as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR");
158 });
159
160 it("broken connector referential integrity → 400 with the field-level error", async () => {
161 const { headers, pageId } = await setupBoard();
162 const doc = richCanvas();
163 (doc.elements.c1 as { from: { elementId: string } }).from.elementId = "missing";
164 const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
165 headers,
166 body: doc
167 });
168 expect(res.status).toBe(400);
169 const details = ((await json(res)) as { error: { details?: { issue?: string }[] } }).error.details ?? [];
170 expect(details.some((d) => String(d.issue).includes("missing element"))).toBe(true);
171 });
172
173 it("bad per-type fields (fontSize range, strokeWidth, points parity) → 400", async () => {
174 const { headers, pageId } = await setupBoard();
175 for (const bad of [
176 { version: 1, elements: { t1: { id: "t1", type: "text", x: 0, y: 0, w: 1, h: 1, z: 1, text: "x", fontSize: 999 } } },
177 { version: 1, elements: { f1: { id: "f1", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0, 1], strokeWidth: 2 } } },
178 { version: 1, elements: { f2: { id: "f2", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0], strokeWidth: 99 } } }
179 ]) {
180 const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: bad });
181 expect(res.status).toBe(400);
182 }
183 });
184});
185
186describe("T-167 whiteboard — no-leak + authz", () => {
187 it("a NON-whiteboard page has no canvas (404, not 403 — no kind leak)", async () => {
188 const { headers, workspaceId } = await setupBoard();
189 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
190 headers,
191 body: { title: "plain page" }
192 });
193 expect([200, 201]).toContain(res.status);
194 const plain = (await json(res)) as { id: string; kind: string };
195 expect(plain.kind).toBe("page");
196 const canvas = await app.request("GET", `/api/v1/pages/${plain.id}/whiteboard`, { headers });
197 expect(canvas.status).toBe(404);
198 const put = await app.request("PUT", `/api/v1/pages/${plain.id}/whiteboard`, {
199 headers,
200 body: { version: 1, elements: {} }
201 });
202 expect(put.status).toBe(404);
203 });
204
205 it("unknown page id → 404 (GET and PUT)", async () => {
206 const { headers } = await setupBoard();
207 const g = await app.request("GET", `/api/v1/pages/${crypto.randomUUID()}/whiteboard`, { headers });
208 expect(g.status).toBe(404);
209 const p = await app.request("PUT", `/api/v1/pages/${crypto.randomUUID()}/whiteboard`, {
210 headers,
211 body: { version: 1, elements: {} }
212 });
213 expect(p.status).toBe(404);
214 });
215
216 it("GUEST (VIEW) can read but not save; non-member 403; save is EDIT-gated", async () => {
217 const { headers, workspaceId, pageId } = await setupBoard();
218
219 const guest = await createUser(app.request, { name: "Board Guest" });
220 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
221 const gHeaders = authHeaders(guest.accessToken!);
222 const read = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers: gHeaders });
223 expect(read.status).toBe(200);
224 const write = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
225 headers: gHeaders,
226 body: { version: 1, elements: {} }
227 });
228 expect(write.status).toBe(403);
229
230 const stranger = await createUser(app.request, { name: "Board Stranger" });
231 const sRead = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, {
232 headers: authHeaders(stranger.accessToken!)
233 });
234 expect(sRead.status).toBe(403);
235 const sWrite = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
236 headers: authHeaders(stranger.accessToken!),
237 body: { version: 1, elements: {} }
238 });
239 expect(sWrite.status).toBe(403);
240 });
241});
242
243describe("T-167 whiteboard — body cap (route-scoped 2 MB parser)", () => {
244 it("a canvas body over 2 MB → 413 PAYLOAD_TOO_LARGE; a large-but-valid one saves", async () => {
245 const { headers, pageId } = await setupBoard();
246
247
248
249 const big: Record<string, unknown> = {};
250 for (let i = 0; i < 120; i++) {
251 big[`s${i}`] = {
252 id: `s${i}`,
253 type: "sticky",
254 x: 0,
255 y: 0,
256 w: 100,
257 h: 80,
258 z: i + 1,
259 text: "x".repeat(20_000),
260 color: "yellow"
261 };
262 }
263 const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
264 headers,
265 body: { version: 1, elements: big }
266 });
267 expect(res.status).toBe(413);
268 expect(((await json(res)) as { error: { code: string } }).error.code).toBe("PAYLOAD_TOO_LARGE");
269
270
271
272 const mid: Record<string, unknown> = {};
273 for (let i = 0; i < 15; i++) {
274 mid[`m${i}`] = {
275 id: `m${i}`,
276 type: "sticky",
277 x: i,
278 y: 0,
279 w: 100,
280 h: 80,
281 z: i + 1,
282 text: "y".repeat(20_000),
283 color: "blue"
284 };
285 }
286 const ok = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
287 headers,
288 body: { version: 1, elements: mid }
289 });
290 expect(ok.status).toBe(200);
291 const body = (await json(ok)) as { canvas: { elements: Record<string, unknown> } };
292 expect(Object.keys(body.canvas.elements)).toHaveLength(15);
293 });
294});
295
296
297
298describe("T-170 whiteboard — version snapshots & restore (canvas sidecar)", () => {
299 it("createVersion snapshots the canvas; restore brings it back after edits (audited)", async () => {
300 const { headers, pageId } = await setupBoard();
301
302
303 const put1 = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
304 headers,
305 body: { version: 1, elements: { a1: { id: "a1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "before", color: "yellow" } } }
306 });
307 expect(put1.status).toBe(200);
308 const cv = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers, body: { note: "board v1" } });
309 expect([200, 201]).toContain(cv.status);
310 const versionRow = (await json(cv)) as { id: string; version: number };
311 expect(versionRow.version).toBe(1);
312
313
314 const pv = await app.request("GET", `/api/v1/pages/${pageId}/versions/${versionRow.id}`, { headers });
315 expect(pv.status).toBe(200);
316 const snapshot = ((await json(pv)) as { snapshot: { whiteboard?: { canvas: { elements: Record<string, unknown> } } } }).snapshot;
317 expect(snapshot.whiteboard?.canvas.elements.a1).toBeDefined();
318
319
320 const put2 = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, {
321 headers,
322 body: { version: 1, elements: { b1: { id: "b1", type: "sticky", x: 5, y: 5, w: 60, h: 40, z: 1, text: "after", color: "blue" } } }
323 });
324 expect(put2.status).toBe(200);
325 const mid = (await json(
326 (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers }))
327 )) as { canvas: { elements: Record<string, unknown> } };
328 expect(Object.keys(mid.canvas.elements)).toEqual(["b1"]);
329
330
331 const before = await countAuditEvents(app.prisma, { action: "page.version_restored" });
332 const rr = await app.request("POST", `/api/v1/pages/${pageId}/versions/${versionRow.id}/restore`, { headers });
333 expect([200, 201]).toContain(rr.status);
334 expect(((await json(rr)) as { restored: boolean }).restored).toBe(true);
335 const after = (await json(
336 (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers }))
337 )) as { canvas: { elements: Record<string, unknown> } };
338 expect(Object.keys(after.canvas.elements)).toEqual(["a1"]);
339 expect((after.canvas.elements.a1 as { text: string }).text).toBe("before");
340
341 expect(await countAuditEvents(app.prisma, { action: "page.version_restored" })).toBe(before + 1);
342 });
343
344 it("a version of a NON-whiteboard page has no whiteboard key in its snapshot", async () => {
345 const { headers, workspaceId } = await setupBoard();
346 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "doc" } });
347 expect([200, 201]).toContain(res.status);
348 const pageId = ((await json(res)) as { id: string }).id;
349 const cv = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers });
350 expect([200, 201]).toContain(cv.status);
351 const row = (await json(cv)) as { id: string };
352 const pv = await app.request("GET", `/api/v1/pages/${pageId}/versions/${row.id}`, { headers });
353 expect(pv.status).toBe(200);
354 const snapshot = ((await json(pv)) as { snapshot: Record<string, unknown> }).snapshot;
355 expect(snapshot.whiteboard).toBeUndefined();
356
357 const rr = await app.request("POST", `/api/v1/pages/${pageId}/versions/${row.id}/restore`, { headers });
358 expect([200, 201]).toContain(rr.status);
359 expect(await app.prisma.whiteboard.findUnique({ where: { pageId } })).toBeNull();
360 });
361});
362
363
364
365describe("T-171 whiteboard — convert selection to page/task", () => {
366 async function boardWithText() {
367 const s = await setupBoard();
368 const put = await app.request("PUT", `/api/v1/pages/${s.pageId}/whiteboard`, {
369 headers: s.headers,
370 body: {
371 version: 1,
372 elements: {
373 s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "first line\nsecond line", color: "yellow" },
374 t1: { id: "t1", type: "text", x: 0, y: 0, w: 60, h: 20, z: 2, text: "note text", fontSize: 14 },
375 r1: { id: "r1", type: "rect", x: 0, y: 0, w: 30, h: 40, z: 3, fill: "blue", stroke: null },
376 empty: { id: "empty", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 4, text: "", color: "blue" }
377 }
378 }
379 });
380 expect(put.status).toBe(200);
381 return s;
382 }
383
384 it("text-bearing elements become blocks in a new page (server re-reads the canvas)", async () => {
385 const { headers, pageId } = await boardWithText();
386 const res = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, {
387 headers,
388 body: { elementIds: ["s1", "t1", "r1", "empty", "nope"], target: "page" }
389 });
390 expect([200, 201]).toContain(res.status);
391 const body = (await json(res)) as {
392 page: { id: string; kind: string; title: string };
393 task: null;
394 blockCount: number;
395 };
396 expect(body.page.kind).toBe("page");
397
398 expect(body.page.title).toBe("first line");
399
400 expect(body.blockCount).toBe(4);
401
402 const content = (await json(
403 (await app.request("GET", `/api/v1/pages/${body.page.id}/content`, { headers }))
404 )) as { text: string };
405 expect(content.text).toBe("first line\nfirst line\nsecond line\nnote text");
406
407 expect(content.text).not.toContain("r1");
408
409 expect(await countAuditEvents(app.prisma, { action: "page.whiteboard_converted" })).toBe(1);
410 const src = (await json(
411 (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers }))
412 )) as { canvas: { elements: Record<string, unknown> } };
413 expect(Object.keys(src.canvas.elements)).toHaveLength(4);
414 });
415
416 it("target=task creates a task page + sidecar (A-006)", async () => {
417 const { headers, workspaceId, pageId } = await boardWithText();
418 const res = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, {
419 headers,
420 body: { elementIds: ["s1"], target: "task", title: "From the board" }
421 });
422 expect([200, 201]).toContain(res.status);
423 const body = (await json(res)) as {
424 page: { id: string; kind: string; title: string };
425 task: { id: string; status: string; pageId: string };
426 };
427 expect(body.page.kind).toBe("task");
428 expect(body.page.title).toBe("From the board");
429 expect(body.task.status).toBe("todo");
430 expect(body.task.pageId).toBe(body.page.id);
431
432 const list = (await json(
433 (await app.request("GET", `/api/v1/workspaces/${workspaceId}/tasks?scope=all`, { headers }))
434 )) as { data: Array<{ title: string }> };
435 expect(list.data.some((t) => t.title === "From the board")).toBe(true);
436 expect(await countAuditEvents(app.prisma, { action: "page.task_converted" })).toBe(1);
437 });
438
439 it("400 when nothing has text; 404 on a non-whiteboard page; GUEST cannot convert", async () => {
440 const { headers, workspaceId, pageId } = await boardWithText();
441
442
443 const bad = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, {
444 headers,
445 body: { elementIds: ["r1", "empty"], target: "page" }
446 });
447 expect(bad.status).toBe(400);
448 expect(((await json(bad)) as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR");
449
450
451 const doc = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "doc" } });
452 const docId = ((await json(doc)) as { id: string }).id;
453 const nf = await app.request("POST", `/api/v1/pages/${docId}/whiteboard/convert`, {
454 headers,
455 body: { elementIds: ["s1"], target: "page" }
456 });
457 expect(nf.status).toBe(404);
458
459
460 const guest = await createUser(app.request, { name: "Board Guest" });
461 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
462 const gh = authHeaders(guest.accessToken!);
463 const read = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers: gh });
464 expect(read.status).toBe(200);
465 const denied = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, {
466 headers: gh,
467 body: { elementIds: ["s1"], target: "page" }
468 });
469 expect(denied.status).toBe(403);
470 });
471});
472