1
9import { afterAll, beforeAll, describe, expect, it } from "vitest";
10import { createTestApp, type TestAppHandle } from "./helpers/api";
11import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
12
13let app: TestAppHandle;
14
15interface WireBlock {
16 id: string;
17 type: string;
18 content: { text?: string };
19 position: string;
20}
21
22async function mkPage(wsId: string, headers: Record<string, string>, title = "Diff Page"): Promise<string> {
23 const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } });
24 expect(res.status).toBe(201);
25 return ((await res.json()) as { id: string }).id;
26}
27
28async function mkBlock(pageId: string, headers: Record<string, string>, text: string): Promise<string> {
29 const res = await app.request("POST", "/api/v1/blocks/batch", {
30 headers,
31 body: { pageId, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text } } }] }
32 });
33 expect(res.status).toBe(201);
34 const body = (await res.json()) as { blockIds: string[] };
35 const id = body.blockIds[0];
36 expect(id, "batch upsert returned no blockId").toBeTruthy();
37 return id!;
38}
39
40
41async function blockIdByText(pageId: string, headers: Record<string, string>, text: string): Promise<string> {
42 const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers });
43 expect(res.status).toBe(200);
44 const body = (await res.json()) as { data: WireBlock[] };
45 const hit = body.data.find((b) => b.content?.text === text);
46 expect(hit, `block with text "${text}" not found`).toBeTruthy();
47 return hit!.id;
48}
49
50async function patchBlock(pageId: string, blockId: string, headers: Record<string, string>, patch: Record<string, unknown>) {
51 const res = await app.request("POST", "/api/v1/blocks/batch", {
52 headers,
53 body: { pageId, ops: [{ op: "update", blockId, patch }] }
54 });
55 expect(res.status).toBe(201);
56}
57
58async function deleteBlock(pageId: string, blockId: string, headers: Record<string, string>) {
59 const res = await app.request("POST", "/api/v1/blocks/batch", {
60 headers,
61 body: { pageId, ops: [{ op: "delete", blockId }] }
62 });
63 expect(res.status).toBe(201);
64}
65
66async function createVersion(pageId: string, headers: Record<string, string>, note?: string) {
67 const res = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers, body: note ? { note } : {} });
68 expect(res.status).toBe(201);
69 return (await res.json()) as { id: string; version: number };
70}
71
72beforeAll(async () => {
73 app = await createTestApp();
74}, 60_000);
75afterAll(async () => {
76 await app?.close();
77});
78
79describe("Versions: compare + preview (T-150/151)", () => {
80 it("compare returns added/deleted/modified/moved keyed by block id", async () => {
81 const owner = await createUser(app.request, { name: "Diff Owner" });
82 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Diff WS");
83 const headers = authHeaders(owner.accessToken!);
84 const pageId = await mkPage(ws.workspaceId, headers);
85
86
87 await mkBlock(pageId, headers, "alpha");
88 await mkBlock(pageId, headers, "beta");
89 await mkBlock(pageId, headers, "delta-will-die");
90 const v1 = await createVersion(pageId, headers, "v1");
91
92
93 const alphaId = await blockIdByText(pageId, headers, "alpha");
94 const deltaId = await blockIdByText(pageId, headers, "delta-will-die");
95 await patchBlock(pageId, alphaId, headers, { content: { text: "ALPHA revised" } });
96 await deleteBlock(pageId, deltaId, headers);
97 await mkBlock(pageId, headers, "gamma-new");
98 const v2 = await createVersion(pageId, headers, "v2");
99
100 const res = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v2.version}`, { headers });
101 expect(res.status).toBe(200);
102 const diff = (await res.json()) as {
103 from: number;
104 to: number;
105 added: { id: string; content: { text: string } }[];
106 deleted: { id: string; content: { text: string } }[];
107 modified: { id: string; fields: string[] }[];
108 moved: unknown[];
109 summary: { added: number; deleted: number; modified: number; moved: number; unchanged: number };
110 };
111 expect([diff.from, diff.to]).toEqual([v1.version, v2.version]);
112 expect(diff.added.map((b) => b.content.text)).toEqual(["gamma-new"]);
113 expect(diff.deleted.map((b) => b.content.text)).toEqual(["delta-will-die"]);
114 expect(diff.modified).toEqual([{ id: alphaId, fields: ["content"] }]);
115
116
117
118
119 const titleId = await blockIdByText(pageId, headers, "Diff Page");
120 const betaId = await blockIdByText(pageId, headers, "beta");
121 const movedIds = diff.moved.map((m) => m.id);
122 for (const id of movedIds) expect([titleId, betaId]).toContain(id);
123 expect(diff.summary.moved).toBe(movedIds.length);
124 expect(diff.summary.unchanged).toBe(2 - movedIds.length);
125 expect(diff.summary).toMatchObject({ added: 1, deleted: 1, modified: 1 });
126 });
127
128 it("preview returns the full snapshot for one version", async () => {
129 const owner = await createUser(app.request, { name: "Preview Owner" });
130 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Preview WS");
131 const headers = authHeaders(owner.accessToken!);
132 const pageId = await mkPage(ws.workspaceId, headers, "Preview Title");
133 await mkBlock(pageId, headers, "snapshot text");
134 const v1 = await createVersion(pageId, headers);
135
136 const id = await blockIdByText(pageId, headers, "snapshot text");
137 await patchBlock(pageId, id, headers, { content: { text: "changed after snapshot" } });
138
139 const res = await app.request("GET", `/api/v1/pages/${pageId}/versions/${v1.id}`, { headers });
140 expect(res.status).toBe(200);
141 const body = (await res.json()) as {
142 version: number;
143 note: string | null;
144 snapshot: { page: { title: string }; blocks: { content: { text: string } }[] };
145 };
146 expect(body.version).toBe(1);
147 expect(body.snapshot.page.title).toBe("Preview Title");
148 const texts = (body.snapshot.blocks ?? []).map((b) => b.content?.text).filter(Boolean);
149 expect(texts).toContain("snapshot text");
150 expect(texts).not.toContain("changed after snapshot");
151 });
152
153 it("validation + no-leak: from===to → 400, unknown version → 404, non-member → 404", async () => {
154 const owner = await createUser(app.request, { name: "VCL Owner" });
155 const outsider = await createUser(app.request, { name: "VCL Outsider" });
156 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "VCL WS");
157 const headers = authHeaders(owner.accessToken!);
158 const pageId = await mkPage(ws.workspaceId, headers);
159 await mkBlock(pageId, headers, "x");
160 const v1 = await createVersion(pageId, headers);
161
162
163 const same = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers });
164 expect(same.status).toBe(400);
165
166 const missing = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=1&to=999`, { headers });
167 expect(missing.status).toBe(404);
168
169 const other = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=1&to=999`, {
170 headers: authHeaders(outsider.accessToken!)
171 });
172 expect(other.status).toBe(403);
173 const otherPreview = await app.request("GET", `/api/v1/pages/${pageId}/versions/${v1.id}`, {
174 headers: authHeaders(outsider.accessToken!)
175 });
176 expect(otherPreview.status).toBe(403);
177 });
178
179 it("a revoked viewer loses access to compare/preview (per-request authz)", async () => {
180 const owner = await createUser(app.request, { name: "Revoke Owner" });
181 const member = await createUser(app.request, { name: "Revoke Member" });
182 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Revoke WS");
183 const ownerHeaders = authHeaders(owner.accessToken!);
184 const pageId = await mkPage(ws.workspaceId, ownerHeaders);
185 await mkBlock(pageId, ownerHeaders, "secret-ish");
186 const v1 = await createVersion(pageId, ownerHeaders);
187
188 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
189 const memberHeaders = authHeaders(member.accessToken!);
190 const ok = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers: memberHeaders });
191
192 expect(ok.status).toBe(400);
193
194
195 const revokeRes = await app.request("DELETE", `/api/v1/workspaces/${ws.workspaceId}/members/${member.userId}`, {
196 headers: ownerHeaders
197 });
198 expect(revokeRes.status).toBe(200);
199 const denied = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers: memberHeaders });
200 expect(denied.status).toBe(403);
201 });
202});
203