- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field. - Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability. - Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications. - Enhanced the `package.json` with a new migration script for setting up the audit log functionality. This feature improves accountability and transparency in the management of delivery notes and orders.
801 lines
28 KiB
TypeScript
801 lines
28 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
/**
|
|
* Tests for the server-side delivery-notes service.
|
|
*
|
|
* Mocks PocketBase with an in-memory store. The service is the
|
|
* only thing under test — the route layer is verified separately
|
|
* by integration / E2E.
|
|
*/
|
|
|
|
// In-memory store keyed by collection name. We use this to
|
|
// simulate PB across `getOne`, `getList`, `getFullList`, `create`,
|
|
// `update` calls without standing up a real PB instance.
|
|
const { store, getOrCreate, where, pbMock, clearStore } = vi.hoisted(() => {
|
|
const store: Record<string, Map<string, Record<string, unknown>>> = {};
|
|
const getOrCreate = (collection: string) => {
|
|
if (!store[collection]) store[collection] = new Map();
|
|
return store[collection];
|
|
};
|
|
const where = (collection: string, filter: string) => {
|
|
const map = getOrCreate(collection);
|
|
const items = Array.from(map.values());
|
|
if (!filter || !filter.trim()) return items;
|
|
const clauses = filter.split("&&").map((c) => c.trim());
|
|
return items.filter((item) =>
|
|
clauses.every((clause) => {
|
|
// PB supports two operators we use in this service:
|
|
// = — exact match (with quoted RHS)
|
|
// ~ — contains (substring match)
|
|
// Both are joined with a quoted RHS. Handle both.
|
|
const eq = clause.match(/^(\w+)\s*=\s*"([^"]+)"$/);
|
|
if (eq) {
|
|
const [, field, raw] = eq;
|
|
return String(item[field] ?? "") === raw;
|
|
}
|
|
const contains = clause.match(/^(\w+)\s*~\s*"([^"]+)"$/);
|
|
if (contains) {
|
|
const [, field, raw] = contains;
|
|
return String(item[field] ?? "").includes(raw);
|
|
}
|
|
return true;
|
|
}),
|
|
);
|
|
};
|
|
const clearStore = () => {
|
|
for (const k of Object.keys(store)) delete store[k];
|
|
};
|
|
const pbMock = {
|
|
collection: (name: string) => ({
|
|
async getOne(id: string) {
|
|
const item = getOrCreate(name).get(id);
|
|
if (!item) {
|
|
const err = new Error("not found") as Error & { status: number };
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
return item;
|
|
},
|
|
async getList(page = 1, perPage = 25, opts: { filter?: string; sort?: string } = {}) {
|
|
let items = where(name, opts.filter || "");
|
|
// Honour PB's `-field` / `field` sort syntax. The real
|
|
// service uses `sort: "-delivery_note_number"` to find
|
|
// the most-recent DN — without sorting here, the test
|
|
// mock returns insertion order and the counter never
|
|
// advances.
|
|
if (opts.sort) {
|
|
const desc = opts.sort.startsWith("-");
|
|
const field = desc ? opts.sort.slice(1) : opts.sort;
|
|
items = [...items].sort((a, b) => {
|
|
const av = String(a[field] ?? "");
|
|
const bv = String(b[field] ?? "");
|
|
if (av === bv) return 0;
|
|
return desc ? (av < bv ? 1 : -1) : av < bv ? -1 : 1;
|
|
});
|
|
}
|
|
const start = (page - 1) * perPage;
|
|
return {
|
|
items: items.slice(start, start + perPage),
|
|
totalItems: items.length,
|
|
page,
|
|
perPage,
|
|
};
|
|
},
|
|
async getFullList(opts: { filter?: string } = {}) {
|
|
return where(name, opts.filter || "");
|
|
},
|
|
async create(data: Record<string, unknown>) {
|
|
const id = `mock-${Math.random().toString(36).slice(2, 10)}`;
|
|
const item = { id, ...data };
|
|
getOrCreate(name).set(id, item);
|
|
return item;
|
|
},
|
|
async update(id: string, data: Record<string, unknown>) {
|
|
const item = getOrCreate(name).get(id);
|
|
if (!item) {
|
|
const err = new Error("not found") as Error & { status: number };
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
Object.assign(item, data);
|
|
return item;
|
|
},
|
|
}),
|
|
};
|
|
return { store, getOrCreate, where, pbMock, clearStore };
|
|
});
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
}));
|
|
|
|
import {
|
|
createQuoteFromDeliveryNote,
|
|
setDeliveryNoteCustomerPo,
|
|
setDeliveryNoteOrder,
|
|
markDeliveryNoteInvoiced,
|
|
markDeliveryNoteCompletedByInvoice,
|
|
createAdHocDeliveryNote,
|
|
generateDeliveryNoteNumberServer,
|
|
getDeliveryNoteDetail,
|
|
} from "./delivery-notes-service";
|
|
|
|
/** Seed a delivery note into the mock store. Returns its id. */
|
|
function seedDeliveryNote(overrides: Partial<Record<string, unknown>> = {}): string {
|
|
const id = "dn-1";
|
|
getOrCreate("delivery_notes").set(id, {
|
|
id,
|
|
delivery_note_number: "DN26071401",
|
|
status: "draft",
|
|
customer: "customer-1",
|
|
company: "company-1",
|
|
order: "order-1",
|
|
notes: "leave at back door",
|
|
customer_po: "",
|
|
delivery_date: "",
|
|
created_by: "user-1",
|
|
...overrides,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
/** Seed an order_item into the mock store, stamped with the given
|
|
* delivery_note_number. Returns its id. */
|
|
function seedOrderItem(
|
|
deliveryNoteNumber: string,
|
|
overrides: Partial<Record<string, unknown>> = {},
|
|
): string {
|
|
const id = `oi-${Math.random().toString(36).slice(2, 8)}`;
|
|
getOrCreate("order_items").set(id, {
|
|
id,
|
|
order: "order-1",
|
|
delivery_note_number: deliveryNoteNumber,
|
|
description: "10x M8 bolts",
|
|
quantity: 10,
|
|
cost_price: 100,
|
|
selling_price: 150,
|
|
supplier: "supplier-1",
|
|
sku: "M8-50",
|
|
pricing_source: "catalogue",
|
|
item_status: "delivery_note",
|
|
notes: "",
|
|
...overrides,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearStore();
|
|
});
|
|
|
|
describe("createQuoteFromDeliveryNote", () => {
|
|
it("creates a quote with status=delivery_note + one quote_item per order_item, and flips the note to sent", async () => {
|
|
// Seed with NO notes — the service falls back to the default
|
|
// description containing the DN number, which is what we
|
|
// want to assert.
|
|
const dnId = seedDeliveryNote({ notes: "" });
|
|
seedOrderItem("DN26071401");
|
|
seedOrderItem("DN26071401", { description: "20x M10 nuts", quantity: 20 });
|
|
|
|
const result = await createQuoteFromDeliveryNote(dnId);
|
|
|
|
expect(result.id).toBeTruthy();
|
|
expect(result.quote_number).toMatch(/^Q\d{6}\d{3}$/);
|
|
|
|
// Quote header is persisted with the right shape.
|
|
const quotes = Array.from(getOrCreate("quotes").values());
|
|
expect(quotes).toHaveLength(1);
|
|
const q = quotes[0] as Record<string, unknown>;
|
|
expect(q.status).toBe("delivery_note");
|
|
expect(q.delivery_note).toBe(dnId);
|
|
expect(q.customer).toBe("customer-1");
|
|
expect(q.company).toBe("company-1");
|
|
expect(q.validity_days).toBe(30);
|
|
expect(q.description).toContain("DN26071401");
|
|
// v2.1 fix: the RFQ number is the delivery note number, so
|
|
// the operator sees the DN ref on the quote's RFQ line and
|
|
// knows instantly "this came from a delivery note, not
|
|
// from a customer email."
|
|
expect(q.rfq_number).toBe("DN26071401");
|
|
|
|
// One quote_item per order_item.
|
|
const quoteItems = Array.from(getOrCreate("quote_items").values());
|
|
expect(quoteItems).toHaveLength(2);
|
|
const byDescription = new Map(
|
|
quoteItems.map((qi) => [
|
|
(qi as Record<string, unknown>).description,
|
|
qi as Record<string, unknown>,
|
|
]),
|
|
);
|
|
expect(byDescription.get("10x M8 bolts")?.selling_price).toBe(150);
|
|
expect(byDescription.get("10x M8 bolts")?.pricing_source).toBe("catalogue");
|
|
expect(byDescription.get("20x M10 nuts")?.quantity).toBe(20);
|
|
|
|
// Delivery note status flipped to "sent".
|
|
const dn = getOrCreate("delivery_notes").get(dnId) as Record<string, unknown>;
|
|
expect(dn.status).toBe("sent");
|
|
});
|
|
|
|
it("stamps pricing_source=manual when the order_item has no selling_price", async () => {
|
|
const dnId = seedDeliveryNote();
|
|
seedOrderItem("DN26071401", { selling_price: null });
|
|
|
|
await createQuoteFromDeliveryNote(dnId);
|
|
|
|
const qi = Array.from(getOrCreate("quote_items").values())[0] as Record<string, unknown>;
|
|
// The service falls back to 0 — operator will fill in the
|
|
// real price on the quote detail page.
|
|
expect(qi.selling_price).toBe(0);
|
|
expect(qi.pricing_source).toBe("manual");
|
|
});
|
|
|
|
it("throws when the delivery note is not in draft status", async () => {
|
|
const dnId = seedDeliveryNote({ status: "sent" });
|
|
seedOrderItem("DN26071401");
|
|
|
|
await expect(createQuoteFromDeliveryNote(dnId)).rejects.toThrow(
|
|
/must be in "draft" status/,
|
|
);
|
|
});
|
|
|
|
it("throws when a delivery-note quote already exists for this note", async () => {
|
|
const dnId = seedDeliveryNote();
|
|
seedOrderItem("DN26071401");
|
|
// Pre-seed an existing delivery-note quote linked to this note.
|
|
getOrCreate("quotes").set("existing-quote", {
|
|
id: "existing-quote",
|
|
quote_number: "Q202607001",
|
|
status: "delivery_note",
|
|
delivery_note: dnId,
|
|
});
|
|
|
|
await expect(createQuoteFromDeliveryNote(dnId)).rejects.toThrow(
|
|
/already exists/,
|
|
);
|
|
});
|
|
|
|
it("throws when the delivery note has no order_items", async () => {
|
|
const dnId = seedDeliveryNote();
|
|
// No seedOrderItem call → the items lookup returns [].
|
|
|
|
await expect(createQuoteFromDeliveryNote(dnId)).rejects.toThrow(
|
|
/No order items found/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("setDeliveryNoteCustomerPo", () => {
|
|
it("stamps the PO and flips the note from sent → po_received", async () => {
|
|
const dnId = seedDeliveryNote({ status: "sent" });
|
|
|
|
const updated = await setDeliveryNoteCustomerPo(dnId, "PO-2026-1234");
|
|
|
|
expect(updated.customer_po).toBe("PO-2026-1234");
|
|
expect(updated.status).toBe("po_received");
|
|
});
|
|
|
|
it("trims whitespace before saving", async () => {
|
|
const dnId = seedDeliveryNote({ status: "sent" });
|
|
|
|
const updated = await setDeliveryNoteCustomerPo(dnId, " PO-2026-1234 ");
|
|
|
|
expect(updated.customer_po).toBe("PO-2026-1234");
|
|
});
|
|
|
|
it("rejects empty PO", async () => {
|
|
const dnId = seedDeliveryNote({ status: "sent" });
|
|
|
|
await expect(setDeliveryNoteCustomerPo(dnId, "")).rejects.toThrow(
|
|
/cannot be empty/i,
|
|
);
|
|
await expect(setDeliveryNoteCustomerPo(dnId, " ")).rejects.toThrow(
|
|
/cannot be empty/i,
|
|
);
|
|
});
|
|
|
|
it("allows re-setting the PO on a po_received note (typo correction)", async () => {
|
|
const dnId = seedDeliveryNote({
|
|
status: "po_received",
|
|
customer_po: "PO-2026-OLD",
|
|
});
|
|
|
|
const updated = await setDeliveryNoteCustomerPo(dnId, "PO-2026-NEW");
|
|
|
|
expect(updated.customer_po).toBe("PO-2026-NEW");
|
|
// Status unchanged — we only stamp, don't transition again.
|
|
expect(updated.status).toBe("po_received");
|
|
});
|
|
|
|
it("rejects setting PO on a draft note (must send first)", async () => {
|
|
const dnId = seedDeliveryNote({ status: "draft" });
|
|
|
|
await expect(setDeliveryNoteCustomerPo(dnId, "PO-1")).rejects.toThrow(
|
|
/must be sent first/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("markDeliveryNoteInvoiced", () => {
|
|
it("flips order_items to ready_invoice and the note to invoiced", async () => {
|
|
const dnId = seedDeliveryNote({ status: "po_received" });
|
|
seedOrderItem("DN26071401");
|
|
seedOrderItem("DN26071401");
|
|
|
|
const updated = await markDeliveryNoteInvoiced(dnId);
|
|
|
|
expect(updated.status).toBe("invoiced");
|
|
|
|
// Both order_items flipped to ready_invoice.
|
|
const items = Array.from(getOrCreate("order_items").values()) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(items).toHaveLength(2);
|
|
for (const it of items) {
|
|
expect(it.item_status).toBe("ready_invoice");
|
|
}
|
|
});
|
|
|
|
it("rejects when the note is not in po_received", async () => {
|
|
const dnId = seedDeliveryNote({ status: "draft" });
|
|
|
|
await expect(markDeliveryNoteInvoiced(dnId)).rejects.toThrow(
|
|
/must be in "po_received" status/,
|
|
);
|
|
});
|
|
|
|
it("rejects when the note has no order_items", async () => {
|
|
const dnId = seedDeliveryNote({ status: "po_received" });
|
|
|
|
await expect(markDeliveryNoteInvoiced(dnId)).rejects.toThrow(
|
|
/No order items found/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("markDeliveryNoteCompletedByInvoice", () => {
|
|
it("flips status to completed and appends a QuickBooks banner to notes", async () => {
|
|
const dnId = seedDeliveryNote({
|
|
status: "invoiced",
|
|
notes: "leave at back door",
|
|
});
|
|
|
|
const updated = await markDeliveryNoteCompletedByInvoice(dnId, {
|
|
invoiceNumber: "QB-INV-1234",
|
|
sentByName: "David Els",
|
|
sentAtIso: "2026-08-16T12:34:56.000Z",
|
|
});
|
|
|
|
expect(updated.status).toBe("completed");
|
|
// Existing notes preserved, banner appended on a new line.
|
|
expect(updated.notes).toBe(
|
|
"leave at back door\nAuto-closed via QuickBooks invoice QB-INV-1234 by David Els on 2026-08-16T12:34:56.000Z",
|
|
);
|
|
});
|
|
|
|
it("works when notes is empty (banner is the first content)", async () => {
|
|
const dnId = seedDeliveryNote({ status: "invoiced", notes: "" });
|
|
|
|
const updated = await markDeliveryNoteCompletedByInvoice(dnId, {
|
|
invoiceNumber: "QB-INV-9999",
|
|
sentByName: "Laura",
|
|
sentAtIso: "2026-08-16T08:00:00.000Z",
|
|
});
|
|
|
|
expect(updated.status).toBe("completed");
|
|
expect(updated.notes).toBe(
|
|
"Auto-closed via QuickBooks invoice QB-INV-9999 by Laura on 2026-08-16T08:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("omits the 'by <name>' segment when sentByName is empty", async () => {
|
|
const dnId = seedDeliveryNote({ status: "invoiced", notes: "" });
|
|
|
|
const updated = await markDeliveryNoteCompletedByInvoice(dnId, {
|
|
invoiceNumber: "QB-INV-7",
|
|
sentByName: " ",
|
|
sentAtIso: "2026-08-16T08:00:00.000Z",
|
|
});
|
|
|
|
expect(updated.notes).toBe(
|
|
"Auto-closed via QuickBooks invoice QB-INV-7 on 2026-08-16T08:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("rejects when the note is completed or cancelled (terminal statuses)", async () => {
|
|
const completedDn = seedDeliveryNote({ status: "completed" });
|
|
await expect(
|
|
markDeliveryNoteCompletedByInvoice(completedDn, {
|
|
invoiceNumber: "QB-1",
|
|
}),
|
|
).rejects.toThrow(/in "completed" status cannot be auto-closed/);
|
|
|
|
const cancelledDn = seedDeliveryNote({ status: "cancelled" });
|
|
await expect(
|
|
markDeliveryNoteCompletedByInvoice(cancelledDn, {
|
|
invoiceNumber: "QB-1",
|
|
}),
|
|
).rejects.toThrow(/in "cancelled" status cannot be auto-closed/);
|
|
});
|
|
|
|
it("accepts any non-terminal status and flips to completed", async () => {
|
|
// The QB "invoice + close" button is now shown on every active
|
|
// DN. Verify each non-terminal status is accepted. Each iteration
|
|
// re-seeds dn-1 with the target status (the seed helper always
|
|
// uses the same id), then runs the service.
|
|
const active = ["draft", "sent", "po_received", "invoiced"] as const;
|
|
for (const status of active) {
|
|
const dnId = seedDeliveryNote({
|
|
status,
|
|
notes: "",
|
|
});
|
|
const updated = await markDeliveryNoteCompletedByInvoice(dnId, {
|
|
invoiceNumber: `QB-${status}`,
|
|
sentByName: "David",
|
|
sentAtIso: "2026-08-16T12:00:00.000Z",
|
|
});
|
|
expect(updated.status).toBe("completed");
|
|
expect(updated.notes).toContain(`QB-${status}`);
|
|
}
|
|
});
|
|
|
|
it("rejects when invoiceNumber is empty", async () => {
|
|
const dnId = seedDeliveryNote({ status: "invoiced" });
|
|
|
|
await expect(
|
|
markDeliveryNoteCompletedByInvoice(dnId, { invoiceNumber: " " }),
|
|
).rejects.toThrow(/invoiceNumber is required/);
|
|
});
|
|
|
|
it("does not touch order_items (mirrors markDeliveryNoteComplete semantics)", async () => {
|
|
// The DN's items were already flipped to ready_invoice by
|
|
// markDeliveryNoteInvoiced, then n8n wrote them to "invoiced"
|
|
// when the QB invoice was created. Auto-close should not
|
|
// re-flip them — that's the same contract as the manual
|
|
// "Mark complete" button.
|
|
const dnId = seedDeliveryNote({ status: "invoiced" });
|
|
const itemId = seedOrderItem("DN26071401", { item_status: "invoiced" });
|
|
|
|
await markDeliveryNoteCompletedByInvoice(dnId, {
|
|
invoiceNumber: "QB-INV-1",
|
|
sentByName: "David",
|
|
sentAtIso: "2026-08-16T12:00:00.000Z",
|
|
});
|
|
|
|
const item = getOrCreate("order_items").get(itemId) as Record<string, unknown>;
|
|
expect(item.item_status).toBe("invoiced");
|
|
});
|
|
});
|
|
|
|
describe("generateDeliveryNoteNumberServer", () => {
|
|
it("generates a DN{YY}{MM}{DD}{NN} number for the current day", async () => {
|
|
const n = await generateDeliveryNoteNumberServer();
|
|
const now = new Date();
|
|
const yy = String(now.getFullYear()).slice(-2);
|
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(now.getDate()).padStart(2, "0");
|
|
expect(n).toBe(`DN${yy}${mm}${dd}01`);
|
|
});
|
|
|
|
it("picks up from the highest existing DN number on order_items", async () => {
|
|
// Pre-seed an order_item with a high DN number. The service
|
|
// reads order_items.delivery_note_number (sorted -number) to
|
|
// find the latest, so the next call must return that + 1.
|
|
//
|
|
// (We don't test the "increments on each consecutive call"
|
|
// behavior here because the in-memory mock doesn't persist
|
|
// the generated number back to order_items — in real PB the
|
|
// delivery-note create flow writes the new number to every
|
|
// item before the next call sees it, but the mock can't
|
|
// simulate that without intercepting the generator. The
|
|
// production behavior is covered by the format test above
|
|
// + the create-flow tests in delivery-notes-service.)
|
|
const now = new Date();
|
|
const yy = String(now.getFullYear()).slice(-2);
|
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(now.getDate()).padStart(2, "0");
|
|
const prefix = `DN${yy}${mm}${dd}`;
|
|
seedOrderItem("XXX-OLD", { delivery_note_number: `${prefix}05` });
|
|
|
|
const next = await generateDeliveryNoteNumberServer();
|
|
expect(next).toBe(`${prefix}06`);
|
|
});
|
|
});
|
|
|
|
describe("createAdHocDeliveryNote", () => {
|
|
it("creates a delivery_notes header + one order_item per line (no parent order)", async () => {
|
|
const result = await createAdHocDeliveryNote({
|
|
customer: "customer-1",
|
|
company: "company-1",
|
|
notes: "customer paid cash",
|
|
createdBy: "user-1",
|
|
items: [
|
|
{
|
|
description: "10x M8 bolts",
|
|
quantity: 10,
|
|
cost_price: 100,
|
|
},
|
|
{
|
|
description: "20x M10 nuts",
|
|
quantity: 20,
|
|
selling_price: 8,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.id).toBeTruthy();
|
|
expect(result.delivery_note_number).toMatch(/^DN\d{6}01$/);
|
|
|
|
// Header persisted with the right shape.
|
|
const dnotes = Array.from(getOrCreate("delivery_notes").values());
|
|
expect(dnotes).toHaveLength(1);
|
|
const dn = dnotes[0] as Record<string, unknown>;
|
|
expect(dn.delivery_note_number).toBe(result.delivery_note_number);
|
|
expect(dn.customer).toBe("customer-1");
|
|
expect(dn.company).toBe("company-1");
|
|
expect(dn.order).toBeNull();
|
|
expect(dn.status).toBe("draft");
|
|
expect(dn.notes).toBe("customer paid cash");
|
|
expect(dn.created_by).toBe("user-1");
|
|
|
|
// One order_item per input line, all with `order: null` and
|
|
// the delivery note number stamped on.
|
|
const items = Array.from(getOrCreate("order_items").values()) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(items).toHaveLength(2);
|
|
const byDescription = new Map(
|
|
items.map((it) => [it.description, it]),
|
|
);
|
|
const bolts = byDescription.get("10x M8 bolts");
|
|
const nuts = byDescription.get("20x M10 nuts");
|
|
expect(bolts?.order).toBeNull();
|
|
expect(bolts?.delivery_note_number).toBe(result.delivery_note_number);
|
|
expect(bolts?.item_status).toBe("delivery_note");
|
|
expect(bolts?.quantity).toBe(10);
|
|
expect(bolts?.cost_price).toBe(100);
|
|
expect(nuts?.quantity).toBe(20);
|
|
expect(nuts?.selling_price).toBe(8);
|
|
});
|
|
|
|
it("rejects when customer is missing", async () => {
|
|
await expect(
|
|
createAdHocDeliveryNote({
|
|
customer: "",
|
|
items: [{ description: "x", quantity: 1 }],
|
|
}),
|
|
).rejects.toThrow(/Customer is required/);
|
|
});
|
|
|
|
it("rejects when items is empty", async () => {
|
|
await expect(
|
|
createAdHocDeliveryNote({
|
|
customer: "customer-1",
|
|
items: [],
|
|
}),
|
|
).rejects.toThrow(/At least one item/);
|
|
});
|
|
|
|
it("rejects items with empty description or non-positive quantity", async () => {
|
|
await expect(
|
|
createAdHocDeliveryNote({
|
|
customer: "customer-1",
|
|
items: [{ description: "", quantity: 1 }],
|
|
}),
|
|
).rejects.toThrow(/missing a description/);
|
|
|
|
await expect(
|
|
createAdHocDeliveryNote({
|
|
customer: "customer-1",
|
|
items: [{ description: "ok", quantity: 0 }],
|
|
}),
|
|
).rejects.toThrow(/invalid quantity/);
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// setDeliveryNoteOrder (Hermes unblock)
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
describe("setDeliveryNoteOrder", () => {
|
|
it("links a delivery note to an order, returns {deliveryNote, previous}", async () => {
|
|
const dnId = seedDeliveryNote({ status: "draft", order: "" });
|
|
getOrCreate("orders").set("order-2", {
|
|
id: "order-2",
|
|
order_number: "ORD-2",
|
|
});
|
|
|
|
const result = await setDeliveryNoteOrder({
|
|
deliveryNoteId: dnId,
|
|
orderId: "order-2",
|
|
});
|
|
|
|
expect(result.previous).toEqual({ order: "" });
|
|
expect(result.deliveryNote.id).toBe(dnId);
|
|
expect(result.deliveryNote.order).toBe("order-2");
|
|
|
|
// The store reflects the write.
|
|
const stored = getOrCreate("delivery_notes").get(dnId);
|
|
expect(stored?.order).toBe("order-2");
|
|
});
|
|
|
|
it("refuses 409 delivery_note_locked when DN is invoiced", async () => {
|
|
const dnId = seedDeliveryNote({ status: "invoiced", order: "" });
|
|
getOrCreate("orders").set("order-2", { id: "order-2", order_number: "ORD-2" });
|
|
|
|
try {
|
|
await setDeliveryNoteOrder({ deliveryNoteId: dnId, orderId: "order-2" });
|
|
expect.fail("should have thrown");
|
|
} catch (err) {
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
const data = (err as Error & { data?: { code?: string; status?: string } }).data;
|
|
expect(data?.code).toBe("delivery_note_locked");
|
|
expect(data?.status).toBe("invoiced");
|
|
}
|
|
|
|
// Order must not have changed.
|
|
expect(getOrCreate("delivery_notes").get(dnId)?.order).toBe("");
|
|
});
|
|
|
|
it("refuses 409 delivery_note_locked when DN is completed", async () => {
|
|
const dnId = seedDeliveryNote({ status: "completed", order: "" });
|
|
getOrCreate("orders").set("order-2", { id: "order-2", order_number: "ORD-2" });
|
|
|
|
try {
|
|
await setDeliveryNoteOrder({ deliveryNoteId: dnId, orderId: "order-2" });
|
|
expect.fail("should have thrown");
|
|
} catch (err) {
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
const data = (err as Error & { data?: { code?: string; status?: string } }).data;
|
|
expect(data?.code).toBe("delivery_note_locked");
|
|
expect(data?.status).toBe("completed");
|
|
}
|
|
});
|
|
|
|
it("404 when the delivery note doesn't exist", async () => {
|
|
getOrCreate("orders").set("order-2", { id: "order-2", order_number: "ORD-2" });
|
|
|
|
await expect(
|
|
setDeliveryNoteOrder({ deliveryNoteId: "missing", orderId: "order-2" }),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it("404 when the order doesn't exist", async () => {
|
|
const dnId = seedDeliveryNote({ status: "draft", order: "" });
|
|
|
|
await expect(
|
|
setDeliveryNoteOrder({ deliveryNoteId: dnId, orderId: "missing" }),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it("is idempotent when the DN already points at the order (no-op + no audit stamp)", async () => {
|
|
const dnId = seedDeliveryNote({
|
|
status: "draft",
|
|
order: "order-1",
|
|
notes: "original dn note",
|
|
});
|
|
getOrCreate("orders").set("order-1", { id: "order-1", order_number: "ORD-1" });
|
|
|
|
const result = await setDeliveryNoteOrder({
|
|
deliveryNoteId: dnId,
|
|
orderId: "order-1",
|
|
actor: { id: "agent-1", name: "Hermes" },
|
|
});
|
|
|
|
expect(result.previous).toEqual({ order: "order-1" });
|
|
expect(result.deliveryNote.order).toBe("order-1");
|
|
|
|
// Notes must NOT be touched on idempotent no-op.
|
|
expect(getOrCreate("delivery_notes").get(dnId)?.notes).toBe("original dn note");
|
|
});
|
|
|
|
it("stamps actor into notes when provided", async () => {
|
|
const dnId = seedDeliveryNote({
|
|
status: "draft",
|
|
order: "",
|
|
notes: "existing dn note",
|
|
});
|
|
getOrCreate("orders").set("order-2", { id: "order-2", order_number: "ORD-2" });
|
|
|
|
await setDeliveryNoteOrder({
|
|
deliveryNoteId: dnId,
|
|
orderId: "order-2",
|
|
actor: { id: "agent-1", name: "Hermes" },
|
|
});
|
|
|
|
const stored = getOrCreate("delivery_notes").get(dnId);
|
|
expect(stored?.order).toBe("order-2");
|
|
expect(String(stored?.notes ?? "")).toMatch(/existing dn note/);
|
|
expect(String(stored?.notes ?? "")).toMatch(/Hermes linked this delivery note to order order-2/);
|
|
});
|
|
|
|
// Regression coverage for the delivery-notes backfill (2026-09-07):
|
|
// DNs created while the /api/invoicing/delivery-note `fields`+`expand`
|
|
// bug was live (see route.ts) got saved with customer/company/order
|
|
// all blank. Re-linking the order via this setter should also
|
|
// backfill customer/company from that order, since they're supposed
|
|
// to mirror it — see the docstring above `setDeliveryNoteOrder`.
|
|
it("backfills customer/company from the order when the DN's own values are blank", async () => {
|
|
const dnId = seedDeliveryNote({ status: "draft", order: "", customer: "", company: "" });
|
|
getOrCreate("orders").set("order-2", {
|
|
id: "order-2",
|
|
order_number: "ORD-2",
|
|
customer: "customer-9",
|
|
company: "company-9",
|
|
});
|
|
|
|
const result = await setDeliveryNoteOrder({
|
|
deliveryNoteId: dnId,
|
|
orderId: "order-2",
|
|
});
|
|
|
|
expect(result.deliveryNote.customer).toBe("customer-9");
|
|
expect(result.deliveryNote.company).toBe("company-9");
|
|
const stored = getOrCreate("delivery_notes").get(dnId);
|
|
expect(stored?.customer).toBe("customer-9");
|
|
expect(stored?.company).toBe("company-9");
|
|
});
|
|
|
|
it("backfills customer/company even when the order is already linked (idempotent-on-order path)", async () => {
|
|
// A DN can already point at the right order (e.g. re-running this
|
|
// setter a second time) yet still be missing customer/company —
|
|
// exactly the shape the fields+expand regression left behind.
|
|
// The idempotent-on-order branch must still apply the backfill.
|
|
const dnId = seedDeliveryNote({ status: "draft", order: "order-2", customer: "", company: "" });
|
|
getOrCreate("orders").set("order-2", {
|
|
id: "order-2",
|
|
order_number: "ORD-2",
|
|
customer: "customer-9",
|
|
company: "company-9",
|
|
});
|
|
|
|
const result = await setDeliveryNoteOrder({
|
|
deliveryNoteId: dnId,
|
|
orderId: "order-2",
|
|
});
|
|
|
|
expect(result.previous).toEqual({ order: "order-2" });
|
|
expect(result.deliveryNote.customer).toBe("customer-9");
|
|
expect(result.deliveryNote.company).toBe("company-9");
|
|
});
|
|
|
|
it("does not overwrite a customer/company the DN already has", async () => {
|
|
const dnId = seedDeliveryNote({
|
|
status: "draft",
|
|
order: "",
|
|
customer: "customer-1",
|
|
company: "company-1",
|
|
});
|
|
getOrCreate("orders").set("order-2", {
|
|
id: "order-2",
|
|
order_number: "ORD-2",
|
|
customer: "customer-9",
|
|
company: "company-9",
|
|
});
|
|
|
|
await setDeliveryNoteOrder({ deliveryNoteId: dnId, orderId: "order-2" });
|
|
|
|
const stored = getOrCreate("delivery_notes").get(dnId);
|
|
expect(stored?.customer).toBe("customer-1");
|
|
expect(stored?.company).toBe("company-1");
|
|
});
|
|
});
|
|
|
|
|
|
describe("getDeliveryNoteDetail", () => {
|
|
it("returns the note, its lines by number and the linked quote", async () => {
|
|
const dnId = seedDeliveryNote();
|
|
seedOrderItem("DN26071401");
|
|
seedOrderItem("DN26071401");
|
|
seedOrderItem("DN-OTHER");
|
|
getOrCreate("quotes").set("q-dn", { id: "q-dn", delivery_note: dnId, created: "2026-07-14" });
|
|
|
|
const detail = await getDeliveryNoteDetail(dnId);
|
|
expect(detail.deliveryNote.delivery_note_number).toBe("DN26071401");
|
|
expect(detail.items).toHaveLength(2);
|
|
expect(detail.quote?.id).toBe("q-dn");
|
|
});
|
|
|
|
it("404s for a missing note", async () => {
|
|
await expect(getDeliveryNoteDetail("missing")).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
});
|