MEGAINFRA-WEBAPP/src/app/api/dn-batches/[id]/notes/[noteId]/route.test.ts
DEls 9a6aed50a4 feat(management): add admin planning dashboards
Add turnover, project, retention, and year comparison pages from the supplied workbook, protected by named admin access. Include the local chart gallery, grouped monthly hover, smooth comparison toggle, and monthly income minus cost series.

Require named PocketBase sign-in in every environment and remove the shared PIN bypass. Update access documentation and business-route test fixtures.
2026-10-09 10:16:50 +02:00

664 lines
25 KiB
TypeScript

import "@/test-utils/named-admin-session";
/**
* Tests for PATCH /api/dn-batches/[id]/notes/[noteId].
*
* The operator-correction route. The route's contract:
* 1. Path validation: both id and noteId are 15-char alnum.
* 2. Body validation:
* - Reject non-JSON bodies with 400 invalid_json.
* - Only EDITABLE fields make it through; unknown keys go in
* `rejected` but don't fail the request when at least one
* editable key was accepted.
* - NUMERIC fields must be finite numbers, or null/"" (which
* clear the field). Anything else is 400 not_a_number with
* the offending field name.
* - page_from/page_to are NUMERIC. When both are present in the
* same patch, from>to is 400 page_range_inverted.
* - Empty patches (nothing editable) are 400 nothing_editable
* with the rejected list so the caller can show what was
* ignored.
* 3. Ownership: the note must belong to the batch. Otherwise
* 400 note_not_in_batch (prevents cross-batch edits).
* 4. Status gate: a note with status='promoted' already has a
* delivery_notes row behind it. Editing the candidate would
* silently desync the two — return 409 already_promoted with
* a remediation hint, do not write.
* 5. Write: PATCH the candidate with the accepted keys.
* 6. Response: 200 { ok: true, updated: [accepted keys],
* rejected: [ignored keys] }.
*
* PocketBase is mocked for dn_batch_notes.getOne and .update.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/* ------------------------------------------------------------------ */
/* Hoisted mocks */
/* ------------------------------------------------------------------ */
const { dnBatchNotesGetOne, dnBatchNotesUpdate, viPbAdmin } = vi.hoisted(
() => ({
dnBatchNotesGetOne: vi.fn(),
dnBatchNotesUpdate: vi.fn(),
viPbAdmin: vi.fn(),
}),
);
/**
* The note route touches dn_batch_notes only. dn_batches is not used
* — the batch id is validated by pattern + ownership check, not by
* looking the row up. Any other collection use would be a regression.
*/
function makePb() {
return {
authStore: { token: "test-admin-token" },
collection: (name: string) => {
if (name !== "dn_batch_notes") {
throw new Error(`unexpected collection in test: ${name}`);
}
return {
getOne: dnBatchNotesGetOne,
update: dnBatchNotesUpdate,
};
},
};
}
vi.mock("@/lib/pb", () => ({
pbAdmin: viPbAdmin,
}));
import { pbAdmin } from "@/lib/pb";
vi.mock("@/lib/dn-pipeline/lock", () => ({
withBatchLease: vi.fn(async (_pb, _id, _worker, callback) => ({ acquired: true, value: await callback({ update: async (_collection: string, id: string, data: unknown) => dnBatchNotesUpdate(id, data) }) })),
}));
import { PATCH } from "./route";
const BATCH_ID = "abcdefghijklmno";
const NOTE_ID = "qrstuvwxyz12345";
function patch(body: Record<string, unknown> | string): Request {
const isString = typeof body === "string";
return new Request(
`http://x/api/dn-batches/${BATCH_ID}/notes/${NOTE_ID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: isString ? (body as string) : JSON.stringify(body),
},
);
}
function patchWithIds(
body: Record<string, unknown>,
ids: { id: string; noteId: string },
): Request {
return new Request(`http://x/api/dn-batches/${ids.id}/notes/${ids.noteId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function allUpdates(): Array<{ id: string; data: Record<string, unknown> }> {
return dnBatchNotesUpdate.mock.calls.map((c) => ({
id: c[0] as string,
data: c[1] as Record<string, unknown>,
}));
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
beforeEach(() => {
vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com");
vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password");
dnBatchNotesGetOne.mockReset();
dnBatchNotesUpdate.mockReset();
viPbAdmin.mockReset();
viPbAdmin.mockResolvedValue(makePb() as never);
// Default: candidate note belonging to the batch under test.
dnBatchNotesGetOne.mockResolvedValue({
id: NOTE_ID,
batch: BATCH_ID,
status: "candidate",
});
dnBatchNotesUpdate.mockResolvedValue({ id: NOTE_ID });
});
afterEach(() => {
vi.unstubAllEnvs();
});
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — path validation", () => {
it("returns 400 invalid_id when batch id is malformed", async () => {
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: "short", noteId: NOTE_ID }),
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "invalid_id" });
expect(dnBatchNotesGetOne).not.toHaveBeenCalled();
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("returns 400 invalid_id when noteId is malformed", async () => {
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: "nope" }),
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "invalid_id" });
expect(dnBatchNotesGetOne).not.toHaveBeenCalled();
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — body validation", () => {
it("returns 400 invalid_json when the body is not JSON", async () => {
const res = await PATCH(patch("not json{"), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "invalid_json" });
expect(dnBatchNotesGetOne).not.toHaveBeenCalled();
});
it("returns 400 nothing_editable when no field is editable", async () => {
const res = await PATCH(
patch({ status: "promoted", secret: "x" }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "nothing_editable",
rejected: ["status", "secret"],
});
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — field rejection", () => {
it("accepts editable fields and reports the rest in rejected", async () => {
const res = await PATCH(
patch({
quantity: 7,
status: "promoted", // not editable
secret: "nope", // not editable
unit: "m3",
}),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
// The route walks Object.entries in input order, so updated and
// rejected mirror that order — that's the contract the UI relies
// on to show changes in the order the operator made them.
expect(body.updated).toEqual(["quantity", "unit"]);
expect(body.rejected).toEqual(["status", "secret"]);
const u = allUpdates()[0];
expect(u.id).toBe(NOTE_ID);
expect(u.data.quantity).toBe(7);
expect(u.data.unit).toBe("m3");
expect(u.data.status).toBeUndefined();
expect(u.data.secret).toBeUndefined();
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — numeric coercion", () => {
it("coerces numeric strings to numbers", async () => {
const res = await PATCH(
patch({ quantity: "12", gross_mass: "100.5" }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
const u = allUpdates()[0];
expect(u.data.quantity).toBe(12);
expect(u.data.gross_mass).toBe(100.5);
});
it("treats null as a clear-the-field for numeric fields", async () => {
const res = await PATCH(
patch({ quantity: null }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.quantity).toBeNull();
});
it("treats empty string as a clear-the-field for numeric fields", async () => {
const res = await PATCH(
patch({ nett_mass: "" }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.nett_mass).toBeNull();
});
it("returns 400 not_a_number for non-numeric values on numeric fields", async () => {
const res = await PATCH(
patch({ gross_mass: "not-a-number" }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "not_a_number", field: "gross_mass" });
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("coerces non-numeric strings on text fields with String()", async () => {
const res = await PATCH(
patch({ delivery_note_number: 12345 }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.delivery_note_number).toBe("12345");
});
it("passes null through as empty string on text fields", async () => {
const res = await PATCH(
patch({ unit: null }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.unit).toBe("");
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — page range", () => {
it("returns 400 page_range_inverted when from > to and both are in the same patch", async () => {
const res = await PATCH(
patch({ page_from: 5, page_to: 2 }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "page_range_inverted" });
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("accepts a patch that only sets page_from (range vs to check skipped)", async () => {
const res = await PATCH(
patch({ page_from: 3 }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.page_from).toBe(3);
});
it("accepts equal page_from and page_to (single-page reassignment)", async () => {
const res = await PATCH(
patch({ page_from: 4, page_to: 4 }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data.page_from).toBe(4);
expect(allUpdates()[0].data.page_to).toBe(4);
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — ownership", () => {
it("returns 400 note_not_in_batch when the note's batch doesn't match the path", async () => {
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID,
batch: "OTHERBATCHID123",
status: "candidate",
});
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "note_not_in_batch" });
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("requests only the fields needed for ownership + status checks", async () => {
await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(dnBatchNotesGetOne).toHaveBeenCalledWith(NOTE_ID, {
// manual_overrides too: a correction is merged into what is already
// recorded rather than replacing it.
fields: "id,batch,status,stage,posted_at,delivery_note,checks,manual_overrides,line_posting",
});
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — promoted gate", () => {
it("returns 409 already_promoted when the note is already promoted", async () => {
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID,
batch: BATCH_ID,
status: "promoted",
});
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toBe("already_promoted");
expect(body.detail).toMatch(/delete the delivery note/i);
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("lets a blocked note through to the update", async () => {
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID,
batch: BATCH_ID,
status: "blocked",
});
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(200);
expect(allUpdates()[0].id).toBe(NOTE_ID);
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — write & response", () => {
it("only requests id, batch, status on the pre-write read", async () => {
await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(dnBatchNotesGetOne).toHaveBeenCalledTimes(1);
});
it("PATCHes the note with the accepted payload", async () => {
const res = await PATCH(
patch({
quantity: 9,
unit: "m3",
page_from: 1,
page_to: 3,
delivery_note_number: "DN-77",
}),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
const u = allUpdates()[0];
expect(u.id).toBe(NOTE_ID);
expect(u.data).toEqual({
quantity: 9,
unit: "m3",
page_from: 1,
page_to: 3,
delivery_note_number: "DN-77",
// The document number is rebuilt from the page reads on every run,
// so correcting it has to be recorded as a decision or the original
// misread returns on the next one. Quantity, unit and the page range
// are not currently reapplied after a re-run — that's a separate
// pre-existing gap. This route only persists per-item edits today.
manual_overrides: { delivery_note_number: "DN-77" },
});
});
it("merges a new correction into the ones already recorded", async () => {
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID, batch: BATCH_ID, status: "blocked",
manual_overrides: { delivery_note_number: "DN-77" },
});
const res = await PATCH(patch({ po_number_raw: "PO-9165" }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(200);
expect(allUpdates()[0].data).toEqual({
po_number_raw: "PO-9165",
manual_overrides: { delivery_note_number: "DN-77", po_number_raw: "PO-9165" },
});
await expect(res.json()).resolves.toEqual(expect.objectContaining({ overridden: ["po_number_raw"] }));
});
it("records nothing extra when the edited field is not rebuilt by a run", async () => {
const res = await PATCH(patch({ quantity: 12 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(200);
expect(allUpdates()[0].data).toEqual({ quantity: 12 });
});
it("persists per-item edits into manual_overrides.item_edits (separate from item links)", async () => {
// Field edits go into item_edits, NOT items. The `items` namespace
// is reserved for the per-line purchase_order_item link ids written
// by resolve-item — those are strings, and sharing the namespace
// would silently overwrite a link when the operator edits a field.
const res = await PATCH(
patch({
items: [
{ lineIndex: 0, unit: "ton" },
{ lineIndex: 1, qty: 8, unit: "LE", description: "200x6m HDPE PIPE" },
],
}),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual(expect.objectContaining({
ok: true,
itemEditedIndices: [0, 1],
updated: [],
}));
expect(allUpdates()[0].data).toEqual({
manual_overrides: {
item_edits: {
"0": { unit: "ton" },
"1": { qty: 8, unit: "LE", description: "200x6m HDPE PIPE" },
},
},
});
});
it("merges new item edits with existing manual_overrides without clobbering them", async () => {
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID,
batch: BATCH_ID,
status: "candidate",
manual_overrides: {
delivery_note_number: "DN-77",
item_edits: { "0": { unit: "ton" } },
},
});
const res = await PATCH(
patch({
po_number_raw: "MEGCPO9165",
items: [{ lineIndex: 1, qty: 12, unit: "ea" }],
}),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data).toEqual({
po_number_raw: "MEGCPO9165",
manual_overrides: {
delivery_note_number: "DN-77",
po_number_raw: "MEGCPO9165",
item_edits: {
"0": { unit: "ton" }, // preserved from prior overrides
"1": { qty: 12, unit: "ea" }, // newly added
},
},
});
});
it("preserves manual item links (manual_overrides.items) alongside item_edits", async () => {
// The two namespaces serve different decisions: items carries the
// per-line PO link id (string), item_edits carries field corrections
// (object). An operator who pins a line to a PO line AND edits its
// unit must not lose the link when the field edit is saved.
dnBatchNotesGetOne.mockResolvedValueOnce({
id: NOTE_ID,
batch: BATCH_ID,
status: "candidate",
manual_overrides: {
// Pre-existing link override from resolve-item
items: { "0": "poi-abc" },
},
});
const res = await PATCH(
patch({
items: [{ lineIndex: 0, unit: "ton" }],
}),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(200);
expect(allUpdates()[0].data).toEqual({
manual_overrides: {
items: { "0": "poi-abc" }, // untouched
item_edits: { "0": { unit: "ton" } }, // new
},
});
});
it("returns 400 when items is not an array", async () => {
const res = await PATCH(
patch({ items: "not-an-array" }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "items_must_be_array" });
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
it("returns 400 when an item entry lacks a valid lineIndex", async () => {
const res = await PATCH(
patch({ items: [{ unit: "ton" }] }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "items_entry_lineIndex_invalid" });
});
it("returns 400 when an item entry has an unknown field", async () => {
const res = await PATCH(
patch({ items: [{ lineIndex: 0, purchase_order_item: "x" }] }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
// purchase_order_item is intentionally excluded — that field is
// owned by the resolve-item route, not the body-edit one.
expect(await res.json()).toEqual({ error: "items_entry_unknown_field:purchase_order_item" });
});
it("returns 400 when an item entry's qty is not numeric", async () => {
const res = await PATCH(
patch({ items: [{ lineIndex: 0, qty: "twelve" }] }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "items_entry_qty_not_number" });
});
it("returns 400 nothing_editable when only the items key was sent with no actual edits", async () => {
const res = await PATCH(
patch({ items: [] }),
{ params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "nothing_editable", rejected: [] });
});
it("returns 500 with the error message when PB update throws", async () => {
dnBatchNotesUpdate.mockRejectedValueOnce(new Error("PB write failed"));
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
const body = await res.json();
expect(res.status).toBe(500);
expect(body).toEqual({ error: "PB write failed" });
});
it("returns 500 with error=update_failed when the throw is not an Error", async () => {
// The brief's code falls back to the literal "update_failed"
// when PocketBase rejects with something other than an Error.
dnBatchNotesUpdate.mockRejectedValueOnce("string-only");
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
const body = await res.json();
expect(res.status).toBe(500);
expect(body).toEqual({ error: "update_failed" });
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — getOne error", () => {
it("returns 500 with the error message when the ownership read throws", async () => {
dnBatchNotesGetOne.mockRejectedValueOnce(new Error("PB read failed"));
const res = await PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
const body = await res.json();
expect(res.status).toBe(500);
expect(body).toEqual({ error: "PB read failed" });
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
});
});
describe("PATCH /api/dn-batches/[id]/notes/[noteId] — admin auth", () => {
it("awaits pbAdmin before doing any PocketBase calls", async () => {
let resolveAdmin!: (pb: unknown) => void;
viPbAdmin.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveAdmin = resolve;
}),
);
const inflight = PATCH(patch({ quantity: 5 }), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
// The route awaits params, then request.json(), then pbAdmin.
// Yield to the event loop so all intermediate awaits settle and
// the route reaches the mocked pbAdmin (which captures
// resolveAdmin before we resolve it).
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dnBatchNotesGetOne).not.toHaveBeenCalled();
expect(dnBatchNotesUpdate).not.toHaveBeenCalled();
expect(typeof resolveAdmin).toBe("function");
resolveAdmin(makePb());
const res = await inflight;
expect(res.status).toBe(200);
});
it("does not call pbAdmin when the body fails JSON parsing", async () => {
const res = await PATCH(patch("not json{"), {
params: Promise.resolve({ id: BATCH_ID, noteId: NOTE_ID }),
});
expect(res.status).toBe(400);
expect(viPbAdmin).not.toHaveBeenCalled();
});
it("does not call pbAdmin when the path params are invalid", async () => {
const res = await PATCH(
patchWithIds({ quantity: 5 }, { id: "no", noteId: NOTE_ID }),
{ params: Promise.resolve({ id: "no", noteId: NOTE_ID }) },
);
expect(res.status).toBe(400);
expect(viPbAdmin).not.toHaveBeenCalled();
});
});
it('preserves a confirmed document classification as an operator override',async()=>{
const response=await PATCH(patch({doc_type:'delivery_note'}),{params:Promise.resolve({id:BATCH_ID,noteId:NOTE_ID})});
expect(response.status).toBe(200);
expect(allUpdates()[0].data).toMatchObject({doc_type:'delivery_note',manual_overrides:{doc_type:'delivery_note'}});
});
it('rejects unsupported document classifications',async()=>{
const response=await PATCH(patch({doc_type:'quotation'}),{params:Promise.resolve({id:BATCH_ID,noteId:NOTE_ID})});
expect(response.status).toBe(400);expect(allUpdates()).toHaveLength(0);
});
it.each([{status:'dismissed'},{stage:'posted'},{status:'posted'},{posted_at:'2026-10-03T12:00:00Z'},{delivery_note:'postedreceipt01'}])('protects terminal records from classification corrections (%j)',async terminal=>{
dnBatchNotesGetOne.mockResolvedValue({id:NOTE_ID,batch:BATCH_ID,status:'candidate',...terminal});
const response=await PATCH(patch({doc_type:'delivery_note'}),{params:Promise.resolve({id:BATCH_ID,noteId:NOTE_ID})});
expect(response.status).toBe(409);expect(allUpdates()).toHaveLength(0);
});
it('invalidates old readiness and preview atomically when classification changes',async()=>{
dnBatchNotesGetOne.mockResolvedValue({id:NOTE_ID,batch:BATCH_ID,status:'candidate',stage:'ready_to_post',checks:[{id:'document_type',passed:true},{id:'po_resolved',passed:true}]});
await PATCH(patch({doc_type:'unknown'}),{params:Promise.resolve({id:BATCH_ID,noteId:NOTE_ID})});
expect(allUpdates()[0].data).toMatchObject({stage:'needs_review',status:'blocked',reconciliation_preview:null,checks:[{id:'po_resolved',passed:true},{id:'document_type',passed:false}]});
});