fix: bind posting audit to delivery note

This commit is contained in:
DEls 2026-09-19 18:59:52 +02:00
parent 4b0da73dcf
commit eed128ebb3
2 changed files with 13 additions and 4 deletions

View file

@ -34,7 +34,7 @@ vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({
if (name === "dn_review_events") return {
getFullList: vi.fn(async () => state.events),
create: vi.fn(async (event: Record<string, unknown>) => {
if (state.auditCollision) { state.events.push({ id: "winner", event_key: event.event_key, batch: BATCH, note: NOTE, action: "post" }); throw new Error("unique event key"); }
if (state.auditCollision) { state.events.push({ id: "winner", event_key: event.event_key, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: DELIVERY } }); throw new Error("unique event key"); }
if (state.auditFails) throw new Error("audit unavailable"); state.events.push(event); return { id: "event" };
}),
};
@ -137,6 +137,14 @@ describe("post delivery note route", () => {
expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" });
});
it("does not accept a same-note audit event pointing at another delivery note", async () => {
state.events = [{ id: "wrong-dn", event_key: `dn-post:${NOTE}`, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: "dn0000000000002" } }];
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" });
});
it("fails closed when a posted delivery note no longer matches its PO/site association", async () => {
state.note = { ...state.note, status: "promoted", stage: "posted", delivery_note: DELIVERY };
state.delivery = { id: DELIVERY, batch_note: NOTE, batch: BATCH, site: "site00000000002", purchase_order: PO };

View file

@ -195,9 +195,10 @@ type AuditResult = { ok: true } | { ok: false; error: "audit_unavailable" | "aud
async function ensurePostedAudit(pb: Awaited<ReturnType<typeof pbAdmin>>, batchId: string, noteId: string, deliveryNoteId: string): Promise<AuditResult> {
const eventKey = `dn-post:${noteId}`;
const identity = (event: Record<string, unknown>) => relationId(event.batch) === batchId && relationId(event.note) === noteId && event.action === "post";
const identity = (event: Record<string, unknown>) => relationId(event.batch) === batchId && relationId(event.note) === noteId && event.action === "post" &&
object(event.after) && relationId(event.after.delivery_note) === deliveryNoteId;
try {
const events = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action" }) as Record<string, unknown>[];
const events = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action,after" }) as Record<string, unknown>[];
if (events.length > 0) return identity(events[0]) ? { ok: true } : { ok: false, error: "audit_key_collision" };
try {
await pb.collection("dn_review_events").create({ event_key: eventKey, batch: batchId, note: noteId, action: "post", before: { stage: "ready_to_post" }, after: { stage: "posted", delivery_note: deliveryNoteId }, reason: "candidate posted", actor: "web" });
@ -205,7 +206,7 @@ async function ensurePostedAudit(pb: Awaited<ReturnType<typeof pbAdmin>>, batchI
} catch {
// A concurrent writer may have won the deterministic key. Re-read and
// only confirm success when the winner belongs to this batch/note.
const winner = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action" }) as Record<string, unknown>[];
const winner = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action,after" }) as Record<string, unknown>[];
if (winner.length > 0) return identity(winner[0]) ? { ok: true } : { ok: false, error: "audit_key_collision" };
return { ok: false, error: "audit_unavailable" };
}