This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
476 lines
16 KiB
TypeScript
476 lines
16 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,
|
|
markDeliveryNoteInvoiced,
|
|
createAdHocDeliveryNote,
|
|
generateDeliveryNoteNumberServer,
|
|
} 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("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/);
|
|
});
|
|
});
|