1
9import { afterAll, beforeAll, describe, expect, it } from "vitest";
10import { createTestApp, type TestAppHandle } from "./helpers/api";
11import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
12
13let app: TestAppHandle;
14
15beforeAll(async () => {
16 app = await createTestApp();
17}, 180_000);
18afterAll(async () => {
19 await app?.close();
20});
21
22const json = async (res: Response): Promise<Record<string, unknown>> =>
23 (await res.json().catch(() => ({}))) as Record<string, unknown>;
24
25async function setupWorkspaceAndPage(
26 title = "Block Page",
27): Promise<{
28 headers: Record<string, string>;
29 workspaceId: string;
30 pageId: string;
31}> {
32 const user = await createUser(app.request, { name: "Block Owner" });
33 const { workspaceId } = await createWorkspace(
34 app.request,
35 authHeaders(user.accessToken!),
36 "Blocks WS",
37 );
38 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
39 headers: authHeaders(user.accessToken!),
40 body: { title },
41 });
42 expect(res.status).toBe(201);
43 return {
44 headers: authHeaders(user.accessToken!),
45 workspaceId,
46
47 pageId: ((await json(res)) as { id: string }).id,
48 };
49}
50
51
52async function liveBlocks(pageId: string) {
53 return app.prisma.block.findMany({
54 where: { pageId, deletedAt: null },
55 orderBy: { position: "asc" },
56 });
57}
58
59describe("Blocks integration", () => {
60 it("batch insert: 3 blocks get strictly increasing fractional positions", async () => {
61 const { headers, pageId } = await setupWorkspaceAndPage();
62 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
63 headers,
64 body: {
65 operations: [
66 { op: "insert", type: "PARAGRAPH", content: { text: "one" }, afterId: null },
67 { op: "insert", type: "HEADING_1", content: { text: "two" }, afterId: "PREV" },
68 { op: "insert", type: "TODO", content: { text: "three" }, afterId: "PREV" },
69 ],
70 },
71 });
72 expect([200, 201]).toContain(res.status);
73
74 const blocks = await liveBlocks(pageId);
75 expect(blocks.length).toBe(3);
76 expect(blocks.map((b) => b.type)).toEqual(["PARAGRAPH", "HEADING_1", "TODO"]);
77
78 const positions = blocks.map((b) => b.position);
79 for (let i = 1; i < positions.length; i++) {
80 expect(positions[i] > positions[i - 1]).toBe(true);
81 }
82 expect(positions.every((p) => /^\d+$/.test(p))).toBe(false);
83
84 expect((blocks[0].content as { text: string }).text).toBe("one");
85 });
86
87 it("insert between two blocks: position falls between their positions", async () => {
88 const { headers, pageId } = await setupWorkspaceAndPage("Between");
89 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
90 headers,
91 body: {
92 operations: [
93 { op: "insert", type: "PARAGRAPH", content: { text: "A" }, afterId: null },
94 { op: "insert", type: "PARAGRAPH", content: { text: "B" }, afterId: "PREV" },
95 ],
96 },
97 });
98 expect([200, 201]).toContain(res.status);
99 const blocks = await liveBlocks(pageId);
100 const [a, b] = blocks;
101
102 const mid = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
103 headers,
104 body: {
105 operations: [
106 { op: "insert", type: "PARAGRAPH", content: { text: "C" }, afterId: a.id },
107 ],
108 },
109 });
110 expect([200, 201]).toContain(mid.status);
111 const after = await liveBlocks(pageId);
112 expect(after.map((x) => (x.content as { text: string }).text)).toEqual(["A", "C", "B"]);
113 const c = after[1];
114 expect(c.position > a.position).toBe(true);
115 expect(c.position < b.position).toBe(true);
116 });
117
118 it("update: block content + version bump + BlockVersion snapshot", async () => {
119 const { headers, pageId } = await setupWorkspaceAndPage("Update");
120 await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
121 headers,
122 body: {
123 operations: [
124 { op: "insert", type: "PARAGRAPH", content: { text: "v1" }, afterId: null },
125 ],
126 },
127 });
128 const [block] = await liveBlocks(pageId);
129 const vBefore = block.version;
130
131 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
132 headers,
133 body: {
134 operations: [
135 { op: "update", id: block.id, content: { text: "v2" } },
136 ],
137 },
138 });
139 expect([200, 201]).toContain(res.status);
140
141 const after = await app.prisma.block.findUniqueOrThrow({ where: { id: block.id } });
142 expect(after.version).toBe(vBefore + 1);
143 expect((after.content as { text: string }).text).toBe("v2");
144
145 const snapshot = await app.prisma.blockVersion.findFirst({
146 where: { blockId: block.id, version: vBefore },
147 });
148 expect(snapshot).not.toBeNull();
149 expect((snapshot!.content as { text: string }).text).toBe("v1");
150 });
151
152 it("move: reorder swaps positions; order-by-position matches requested order", async () => {
153 const { headers, pageId } = await setupWorkspaceAndPage("Move");
154 await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
155 headers,
156 body: {
157 operations: [
158 { op: "insert", type: "PARAGRAPH", content: { text: "one" }, afterId: null },
159 { op: "insert", type: "PARAGRAPH", content: { text: "two" }, afterId: "PREV" },
160 { op: "insert", type: "PARAGRAPH", content: { text: "three" }, afterId: "PREV" },
161 ],
162 },
163 });
164 const [one, two] = await liveBlocks(pageId);
165
166
167 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
168 headers,
169 body: {
170 operations: [{ op: "move", id: one.id, afterId: two.id }],
171 },
172 });
173 expect([200, 201]).toContain(res.status);
174 const after = await liveBlocks(pageId);
175 expect(after.map((x) => (x.content as { text: string }).text)).toEqual([
176 "two",
177 "three",
178 "one",
179 ]);
180 });
181
182 it("delete: soft delete (deletedAt), excluded from live reads; purge removes row", async () => {
183 const { headers, pageId } = await setupWorkspaceAndPage("Delete");
184 await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
185 headers,
186 body: {
187 operations: [
188 { op: "insert", type: "PARAGRAPH", content: { text: "keep" }, afterId: null },
189 { op: "insert", type: "PARAGRAPH", content: { text: "kill" }, afterId: "PREV" },
190 ],
191 },
192 });
193 const blocks = await liveBlocks(pageId);
194 const kill = blocks.find((b) => (b.content as { text: string }).text === "kill")!;
195
196 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
197 headers,
198 body: { operations: [{ op: "delete", id: kill.id }] },
199 });
200 expect([200, 201]).toContain(res.status);
201
202
203 const row = await app.prisma.block.findUniqueOrThrow({ where: { id: kill.id } });
204 expect(row.deletedAt).not.toBeNull();
205 const live = await liveBlocks(pageId);
206 expect(live.length).toBe(1);
207 expect(live[0].id).not.toBe(kill.id);
208
209
210 const purge = await app.request("DELETE", `/api/v1/blocks/${kill.id}?purge=true`, {
211 headers,
212 });
213 expect([200, 204]).toContain(purge.status);
214 expect(await app.prisma.block.findUnique({ where: { id: kill.id } })).toBeNull();
215 });
216
217 it("batch atomicity: one invalid op → 400 and NO partial application", async () => {
218 const { headers, pageId } = await setupWorkspaceAndPage("Atomic");
219 await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
220 headers,
221 body: {
222 operations: [
223 { op: "insert", type: "PARAGRAPH", content: { text: "valid" }, afterId: null },
224 ],
225 },
226 });
227 const beforeCount = (await liveBlocks(pageId)).length;
228
229
230 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
231 headers,
232 body: {
233 operations: [
234 { op: "insert", type: "PARAGRAPH", content: { text: "would be inserted" }, afterId: null },
235 { op: "update", id: "nonexistent-block-id", content: { text: "nope" } },
236 ],
237 },
238 });
239 expect(res.status).toBe(400);
240
241
242 const afterCount = (await liveBlocks(pageId)).length;
243 expect(afterCount).toBe(beforeCount);
244 });
245
246 it("batch on a page from ANOTHER workspace is rejected (cross-tenant)", async () => {
247 const user = await createUser(app.request, { name: "XOwner" });
248 const other = await createUser(app.request, { name: "XOther" });
249 const { workspaceId } = await createWorkspace(
250 app.request,
251 authHeaders(user.accessToken!),
252 "X WS",
253 );
254 const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
255 headers: authHeaders(user.accessToken!),
256 body: { title: "X Page" },
257 });
258
259 const pageId = ((await json(pg)) as { id: string }).id;
260
261 const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, {
262 headers: authHeaders(other.accessToken!),
263 body: {
264 operations: [
265 { op: "insert", type: "PARAGRAPH", content: { text: "intrusion" }, afterId: null },
266 ],
267 },
268 });
269 expect([403, 404]).toContain(res.status);
270 expect((await liveBlocks(pageId)).length).toBe(0);
271 });
272});
273