// @vitest-environment node // No DOM needed (the queue is a plain class; node-env tests are bannered, // matching the other web unit tests). /** * TASK-0136 — `BlockBatchQueue.dropBlock`, the guard that lets the * editor's delete-on-blur cleanup remove a block whose 500 ms-debounced * content save is still queued. Without it the next flush would send an * `update` for a deleted id and the batch endpoint 400s (op validated * against the row), aborting the WHOLE batch. * * Also locks in the invariant that `dropBlock` never touches ops for OTHER * blocks (a flush must still carry them, in order). */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { batchMock } = vi.hoisted(() => ({ batchMock: vi.fn().mockResolvedValue({ applied: 0 }) })); vi.mock("../api", () => ({ blockApi: { batch: batchMock }, })); import { BlockBatchQueue } from "../batch-queue"; import type { BatchBlockOp } from "../api"; const UPDATE = (id: string, content: unknown): BatchBlockOp => ({ op: "update", blockId: id, patch: { content }, }); describe("BlockBatchQueue.dropBlock", () => { beforeEach(() => { vi.useFakeTimers(); batchMock.mockClear(); // vitest.config.ts sets `restoreMocks: true` — every vi.fn() is restored // (implementation wiped) before each test, so re-arm the resolved value // here instead of relying on the hoisted initial setup. batchMock.mockResolvedValue({ applied: 0 }); }); afterEach(() => { vi.useRealTimers(); }); it("drops every pending op that touches the block (both op shapes)", () => { const q = new BlockBatchQueue("page-1", 5000); q.queue([ UPDATE("a", "
keep
"), { op: "upsert", blockId: "b", block: { id: "b", parentId: null, type: "paragraph", content: "" } }, UPDATE("b", "gone
"), UPDATE("c", "keep
"), ]); expect(q.pendingCount).toBe(4); q.dropBlock("b"); // The two ops for "b" (the `blockId` update AND the `block.id` upsert) // are gone; the others remain, in order. expect(q.pendingCount).toBe(2); vi.advanceTimersByTime(5000); const ops = batchMock.mock.calls[0]?.[1] as BatchBlockOp[]; expect(ops.map((o) => o.blockId)).toEqual(["a", "c"]); }); it("keeps a sibling's pending ops so the flush still lands them", () => { const q = new BlockBatchQueue("page-1", 5000); q.queue(UPDATE("keep", "x
")); q.dropBlock("deleted"); expect(q.pendingCount).toBe(1); q.dispose(); expect(q.pendingCount).toBe(1); }); it("is a no-op on an empty queue (no throw, no timer change)", () => { const q = new BlockBatchQueue("page-1", 5000); expect(() => q.dropBlock("ghost")).not.toThrow(); expect(q.pendingCount).toBe(0); q.dispose(); }); it("a flush after dropBlock never carries the deleted id (the race that 400s the batch)", () => { const q = new BlockBatchQueue("page-1", 5000); // The user typed, then cleared the block, then it lost focus: a content // save is queued, and the blur cleanup deletes the block + drops the op. q.queue(UPDATE("victim", "")); q.dropBlock("victim"); // Another block edited in the same window must still flush. q.queue(UPDATE("other", "real
")); vi.advanceTimersByTime(5000); expect(batchMock).toHaveBeenCalledTimes(1); const ops = batchMock.mock.calls[0]?.[1] as BatchBlockOp[]; expect(ops).toEqual([{ op: "update", blockId: "other", patch: { content: "real
" } }]); }); });