1
19import { describe, expect, it, vi } from "vitest";
20import { BlockService } from "../block.service.js";
21
22
23
24
25
26
27vi.mock("../../../lib/authorize.js", () => ({
28 assertCan: vi.fn(async () => "EDIT" as const)
29}));
30
31interface PageState {
32 id: string;
33 workspaceId: string;
34 version: number;
35 deletedAt: null;
36}
37
38interface TxLog {
39 casBase: number | null;
40 created: number;
41
43 casCount: number;
44}
45
46function makeMockPrisma(page: PageState, tx: TxLog, casCountOverride?: number) {
47 const log: TxLog = { ...tx, casCount: casCountOverride ?? 1 };
48 const prisma = {
49 page: {
50
51 findUnique: vi.fn(async () => ({
52 id: page.id,
53 workspaceId: page.workspaceId,
54 deletedAt: null,
55 version: page.version
56 })),
57
58
59
60 updateMany: vi.fn()
61 },
62
63 workspaceMember: {
64 findUnique: vi.fn(async () => ({ role: "MEMBER" }))
65 },
66 teamMember: { findMany: vi.fn(async () => []) },
67 permission: { findMany: vi.fn(async () => []) },
68 block: {
69 create: vi.fn(async () => ({ id: "blk_new" }))
70 },
71 $transaction: vi.fn(async (fn: (tx: unknown) => Promise<number>) => {
72 const txClient = {
73 page: {
74 updateMany: vi.fn(async (args: { where: { id: string; version: number } }) => {
75 log.casBase = args.where.version;
76 return { count: log.casCount };
77 })
78 },
79 block: {
80 create: prisma.block.create
81 }
82 };
83 return fn(txClient);
84 })
85 };
86 return { prisma, log };
87}
88
89
90
91function makeService(prisma: unknown) {
92 const audit = { audit: vi.fn(async () => undefined) };
93 const events = { publish: vi.fn(async () => undefined) };
94
95
96 const cache = {} as never;
97 const notifications = { notifyWatchersForPageUpdate: vi.fn(async () => 0) };
98 const service = new BlockService(prisma as never, audit as never, events as never, cache, notifications as never);
99 return { service, prisma, audit, events, notifications };
100}
101
102
103
104const UP = {
105 op: "upsert" as const,
106 block: { type: "PARAGRAPH" as const, content: { text: "hi" }, properties: {}, position: "a1" }
107};
108
109describe("REST batch — pages.version CAS (slice 3)", () => {
110 it("stale baseVersion → 409 CONFLICT carrying the CURRENT version, nothing written", async () => {
111 const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 9, deletedAt: null };
112 const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 });
113 const { service } = makeService(prisma);
114
115 await expect(
116 service.batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never)
117 ).rejects.toMatchObject({ code: "CONFLICT", httpStatus: 409 });
118
119
120
121
122 const err = (await service
123 .batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never)
124 .catch((e: unknown) => e)) as { details?: Array<Record<string, unknown>> };
125 const flat = (err.details ?? []).flatMap((d) => Object.values(d));
126 expect(flat).toContain(9);
127 expect(err.details?.[0]).toMatchObject({ issue: "stale_base_version", received: 5 });
128
129 expect((prisma.$transaction as unknown as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
130 expect(log.casBase).toBeNull();
131 });
132
133 it("success: advances pages.version and carries it in response + event + audit", async () => {
134 const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 5, deletedAt: null };
135 const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 });
136 const { service, audit, events } = makeService(prisma);
137
138 const res = await service.batch(
139 "pg_1",
140 "u_1",
141 { pageId: "pg_1", baseVersion: 5, ops: [UP] },
142 {} as never
143 );
144 expect(res).toMatchObject({ applied: 1, version: 6 });
145
146
147 expect(log.casBase).toBe(5);
148 expect((prisma.block.create as unknown as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
149
150
151 expect(events.publish).toHaveBeenCalledWith(
152 "page.updated",
153 expect.objectContaining({ pageId: "pg_1", version: 6, blockIds: ["blk_new"] })
154 );
155 expect(audit.audit).toHaveBeenCalledWith(
156 {},
157 undefined,
158 expect.objectContaining({
159 action: "block.batch",
160 metadata: expect.objectContaining({ version: 6 })
161 })
162 );
163 });
164
165 it("legacy client (no baseVersion) → the write proceeds, the version still advances (a concurrent flush's CAS now fails and rebases)", async () => {
166 const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 7, deletedAt: null };
167 const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 });
168 const { service } = makeService(prisma);
169
170 const res = await service.batch("pg_1", "u_1", { pageId: "pg_1", ops: [UP] }, {} as never);
171 expect(res).toMatchObject({ applied: 1, version: 8 });
172 expect(log.casBase).toBe(7);
173 });
174
175 it("in-transaction CAS failure (a concurrent writer raced the tx) → 409 with the freshly re-read version, nothing written", async () => {
176 const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 5, deletedAt: null };
177
178
179 const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 }, 0);
180 const { service } = makeService(prisma);
181
182 await expect(
183 service.batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never)
184 ).rejects.toMatchObject({ code: "CONFLICT", httpStatus: 409 });
185
186 expect(log.casBase).toBe(5);
187
188 expect((prisma.block.create as unknown as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
189 });
190});
191