/** * TASK-0092 slice 3 — the REST batch's `pages.version` CAS (backend- * architecture §12: "the REST batch endpoint remains… uses a CAS on * `pages.version` to serialize against a concurrent flush (flush wins; * REST sees fresh state on retry — CONFLICT carries the new version)"). * * The service is tested directly against a mock Prisma (the same * `updateMany({where: {id, version}})` count-check the internal flush * uses — the two writers now serialize on ONE column): * - a stale `baseVersion` → 409 CONFLICT carrying the CURRENT version * (nothing written, no transaction); * - the authoritative in-transaction CAS failure (a concurrent writer * advanced the version between the pre-check and the UPDATE) → 409 * with the freshly re-read version (nothing written); * - success (fresh or legacy no-baseVersion) → the version is advanced * and carried in the response + the `page.updated` event + the audit * row. */ import { describe, expect, it, vi } from "vitest"; import { BlockService } from "../block.service.js"; // Isolate the CAS logic under test from `assertCan` (the pre-existing // `effectiveLevel` in lib/authorize.ts has a TDZ bug — `teams` is read // inside its own `Promise.all` initializer — part of the 134-error // baseline, 11 failing `authorize.test.ts` tests, NOT this slice). The // permission gate is verified by the integration matrix, not here. vi.mock("../../../lib/authorize.js", () => ({ assertCan: vi.fn(async () => "EDIT" as const) })); interface PageState { id: string; workspaceId: string; version: number; deletedAt: null; } interface TxLog { casBase: number | null; created: number; /** When set, the CAS `updateMany` returns this count (simulate the * concurrent writer racing the transaction). */ casCount: number; } function makeMockPrisma(page: PageState, tx: TxLog, casCountOverride?: number) { const log: TxLog = { ...tx, casCount: casCountOverride ?? 1 }; const prisma = { page: { // requirePageAccess (the pre-check) + the conflict-path re-read. findUnique: vi.fn(async () => ({ id: page.id, workspaceId: page.workspaceId, deletedAt: null, version: page.version })), // The CAS: only the transactional client (tx) is used by the batch; // the top-level page has no updateMany (the service must not CAS // outside the transaction). updateMany: vi.fn() }, // The authorize seam (MEMBER → EDIT, no teams, no grants). workspaceMember: { findUnique: vi.fn(async () => ({ role: "MEMBER" })) }, teamMember: { findMany: vi.fn(async () => []) }, permission: { findMany: vi.fn(async () => []) }, block: { create: vi.fn(async () => ({ id: "blk_new" })) }, $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { const txClient = { page: { updateMany: vi.fn(async (args: { where: { id: string; version: number } }) => { log.casBase = args.where.version; return { count: log.casCount }; }) }, block: { create: prisma.block.create } }; return fn(txClient); }) }; return { prisma, log }; } // `prisma` is a hand-rolled mock (see makeMockPrisma) — typed as `never` // to bypass BlockService's constructor parameter types. function makeService(prisma: unknown) { const audit = { audit: vi.fn(async () => undefined) }; const events = { publish: vi.fn(async () => undefined) }; // 4th arg: the TASK-0135 cache. batch() never reads it (only list() does), // so an empty object suffices for this constructor. const cache = {} as never; const notifications = { notifyWatchersForPageUpdate: vi.fn(async () => 0) }; const service = new BlockService(prisma as never, audit as never, events as never, cache, notifications as never); return { service, prisma, audit, events, notifications }; } // `type` is the DB/REST wire enum (UPPERCASE — dto.ts normalizes codec // case to DB case); `properties` is required on the upsert block. const UP = { op: "upsert" as const, block: { type: "PARAGRAPH" as const, content: { text: "hi" }, properties: {}, position: "a1" } }; describe("REST batch — pages.version CAS (slice 3)", () => { it("stale baseVersion → 409 CONFLICT carrying the CURRENT version, nothing written", async () => { const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 9, deletedAt: null }; const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 }); const { service } = makeService(prisma); await expect( service.batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never) ).rejects.toMatchObject({ code: "CONFLICT", httpStatus: 409 }); // The error carries the new version (the "CONFLICT carries the new // version" contract) + the reason. // The catch() result unions the success shape with the cast error // shape; the reject (asserted just above) guarantees the error form. const err = (await service .batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never) .catch((e: unknown) => e)) as { details?: Array> }; const flat = (err.details ?? []).flatMap((d) => Object.values(d)); expect(flat).toContain(9); // currentVersion expect(err.details?.[0]).toMatchObject({ issue: "stale_base_version", received: 5 }); // No transaction was even started (the fast path rejected first). expect((prisma.$transaction as unknown as ReturnType).mock.calls).toHaveLength(0); expect(log.casBase).toBeNull(); }); it("success: advances pages.version and carries it in response + event + audit", async () => { const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 5, deletedAt: null }; const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 }); const { service, audit, events } = makeService(prisma); const res = await service.batch( "pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never ); expect(res).toMatchObject({ applied: 1, version: 6 }); // The CAS ran on the READ version (5) — the same column the flush's // CAS checks, so the two writers now serialize. expect(log.casBase).toBe(5); expect((prisma.block.create as unknown as ReturnType).mock.calls).toHaveLength(1); // The event + audit carry the new version (the shared PageUpdatedPayload // shape — the realtime flush also populates it). expect(events.publish).toHaveBeenCalledWith( "page.updated", expect.objectContaining({ pageId: "pg_1", version: 6, blockIds: ["blk_new"] }) ); expect(audit.audit).toHaveBeenCalledWith( {}, undefined, expect.objectContaining({ action: "block.batch", metadata: expect.objectContaining({ version: 6 }) }) ); }); it("legacy client (no baseVersion) → the write proceeds, the version still advances (a concurrent flush's CAS now fails and rebases)", async () => { const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 7, deletedAt: null }; const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 }); const { service } = makeService(prisma); const res = await service.batch("pg_1", "u_1", { pageId: "pg_1", ops: [UP] }, {} as never); expect(res).toMatchObject({ applied: 1, version: 8 }); expect(log.casBase).toBe(7); // base = the page's current version }); it("in-transaction CAS failure (a concurrent writer raced the tx) → 409 with the freshly re-read version, nothing written", async () => { const page: PageState = { id: "pg_1", workspaceId: "ws_1", version: 5, deletedAt: null }; // casCount 0 → the CAS UPDATE matched zero rows (the concurrent // writer advanced the version after our pre-check). const { prisma, log } = makeMockPrisma(page, { casBase: null, created: 0, casCount: 0 }, 0); const { service } = makeService(prisma); await expect( service.batch("pg_1", "u_1", { pageId: "pg_1", baseVersion: 5, ops: [UP] }, {} as never) ).rejects.toMatchObject({ code: "CONFLICT", httpStatus: 409 }); // The CAS was attempted at the read version… expect(log.casBase).toBe(5); // …and no block was written (the transaction aborted at the CAS). expect((prisma.block.create as unknown as ReturnType).mock.calls).toHaveLength(0); }); });