/** * AI Software Factory — Integration: Blocks (Phase 4) * * The block engine contract: batch operations (the single round-trip the * editor uses for every structural change), fractional-position invariants * (NEVER array indexes), per-block versioning with snapshots, soft delete, * and batch atomicity (all-or-nothing). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; async function setupWorkspaceAndPage( title = "Block Page", ): Promise<{ headers: Record; workspaceId: string; pageId: string; }> { const user = await createUser(app.request, { name: "Block Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Blocks WS", ); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(user.accessToken!), body: { title }, }); expect(res.status).toBe(201); return { headers: authHeaders(user.accessToken!), workspaceId, // D4 repair: the page create returns the BARE page (no `page` wrapper). pageId: ((await json(res)) as { id: string }).id, }; } /** Ordered list of live (non-deleted) blocks by position. */ async function liveBlocks(pageId: string) { return app.prisma.block.findMany({ where: { pageId, deletedAt: null }, orderBy: { position: "asc" }, }); } describe("Blocks integration", () => { it("batch insert: 3 blocks get strictly increasing fractional positions", async () => { const { headers, pageId } = await setupWorkspaceAndPage(); const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "one" }, afterId: null }, { op: "insert", type: "HEADING_1", content: { text: "two" }, afterId: "PREV" }, { op: "insert", type: "TODO", content: { text: "three" }, afterId: "PREV" }, ], }, }); expect([200, 201]).toContain(res.status); const blocks = await liveBlocks(pageId); expect(blocks.length).toBe(3); expect(blocks.map((b) => b.type)).toEqual(["PARAGRAPH", "HEADING_1", "TODO"]); // Fractional ordering: strictly increasing, and NOT integer indexes. const positions = blocks.map((b) => b.position); for (let i = 1; i < positions.length; i++) { expect(positions[i] > positions[i - 1]).toBe(true); } expect(positions.every((p) => /^\d+$/.test(p))).toBe(false); // never "1","2","3" // Content persisted as JSONB. expect((blocks[0].content as { text: string }).text).toBe("one"); }); it("insert between two blocks: position falls between their positions", async () => { const { headers, pageId } = await setupWorkspaceAndPage("Between"); const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "A" }, afterId: null }, { op: "insert", type: "PARAGRAPH", content: { text: "B" }, afterId: "PREV" }, ], }, }); expect([200, 201]).toContain(res.status); const blocks = await liveBlocks(pageId); const [a, b] = blocks; // Insert C between A and B. const mid = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "C" }, afterId: a.id }, ], }, }); expect([200, 201]).toContain(mid.status); const after = await liveBlocks(pageId); expect(after.map((x) => (x.content as { text: string }).text)).toEqual(["A", "C", "B"]); const c = after[1]; expect(c.position > a.position).toBe(true); expect(c.position < b.position).toBe(true); }); it("update: block content + version bump + BlockVersion snapshot", async () => { const { headers, pageId } = await setupWorkspaceAndPage("Update"); await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "v1" }, afterId: null }, ], }, }); const [block] = await liveBlocks(pageId); const vBefore = block.version; const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "update", id: block.id, content: { text: "v2" } }, ], }, }); expect([200, 201]).toContain(res.status); const after = await app.prisma.block.findUniqueOrThrow({ where: { id: block.id } }); expect(after.version).toBe(vBefore + 1); expect((after.content as { text: string }).text).toBe("v2"); const snapshot = await app.prisma.blockVersion.findFirst({ where: { blockId: block.id, version: vBefore }, }); expect(snapshot).not.toBeNull(); expect((snapshot!.content as { text: string }).text).toBe("v1"); }); it("move: reorder swaps positions; order-by-position matches requested order", async () => { const { headers, pageId } = await setupWorkspaceAndPage("Move"); await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "one" }, afterId: null }, { op: "insert", type: "PARAGRAPH", content: { text: "two" }, afterId: "PREV" }, { op: "insert", type: "PARAGRAPH", content: { text: "three" }, afterId: "PREV" }, ], }, }); const [one, two] = await liveBlocks(pageId); // Move "one" after "two" (end of list). const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [{ op: "move", id: one.id, afterId: two.id }], }, }); expect([200, 201]).toContain(res.status); const after = await liveBlocks(pageId); expect(after.map((x) => (x.content as { text: string }).text)).toEqual([ "two", "three", "one", ]); }); it("delete: soft delete (deletedAt), excluded from live reads; purge removes row", async () => { const { headers, pageId } = await setupWorkspaceAndPage("Delete"); await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "keep" }, afterId: null }, { op: "insert", type: "PARAGRAPH", content: { text: "kill" }, afterId: "PREV" }, ], }, }); const blocks = await liveBlocks(pageId); const kill = blocks.find((b) => (b.content as { text: string }).text === "kill")!; const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [{ op: "delete", id: kill.id }] }, }); expect([200, 201]).toContain(res.status); // Soft-deleted: row present with deletedAt, but not in live reads. const row = await app.prisma.block.findUniqueOrThrow({ where: { id: kill.id } }); expect(row.deletedAt).not.toBeNull(); const live = await liveBlocks(pageId); expect(live.length).toBe(1); expect(live[0].id).not.toBe(kill.id); // Purge (hard delete). const purge = await app.request("DELETE", `/api/v1/blocks/${kill.id}?purge=true`, { headers, }); expect([200, 204]).toContain(purge.status); expect(await app.prisma.block.findUnique({ where: { id: kill.id } })).toBeNull(); }); it("batch atomicity: one invalid op → 400 and NO partial application", async () => { const { headers, pageId } = await setupWorkspaceAndPage("Atomic"); await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "valid" }, afterId: null }, ], }, }); const beforeCount = (await liveBlocks(pageId)).length; // Batch: a valid insert + an op referencing a non-existent block id. const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers, body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "would be inserted" }, afterId: null }, { op: "update", id: "nonexistent-block-id", content: { text: "nope" } }, ], }, }); expect(res.status).toBe(400); // Nothing was applied — count unchanged. const afterCount = (await liveBlocks(pageId)).length; expect(afterCount).toBe(beforeCount); }); it("batch on a page from ANOTHER workspace is rejected (cross-tenant)", async () => { const user = await createUser(app.request, { name: "XOwner" }); const other = await createUser(app.request, { name: "XOther" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "X WS", ); const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(user.accessToken!), body: { title: "X Page" }, }); // D4 repair: the page create returns the BARE page (no `page` wrapper). const pageId = ((await json(pg)) as { id: string }).id; const res = await app.request("POST", `/api/v1/pages/${pageId}/blocks:batch`, { headers: authHeaders(other.accessToken!), body: { operations: [ { op: "insert", type: "PARAGRAPH", content: { text: "intrusion" }, afterId: null }, ], }, }); expect([403, 404]).toContain(res.status); expect((await liveBlocks(pageId)).length).toBe(0); }); });