WFDASH-WEBAPP/src/lib/server/deliveries-service.test.ts
Dave 4a59288a01 feat(deliveries): enhance delivery notes management and UI
- Introduced a new `DeliveryNoteRow` type to track delivery notes alongside items, improving the organization of delivery data.
- Updated the `DeliveryListResponse` type to include delivery notes, allowing for better integration with the existing delivery management system.
- Enhanced the `groupByCustomerOrder` function to accommodate delivery notes, ensuring they are grouped correctly with their respective orders.
- Implemented state management for delivery notes in the `DeliveriesPage` component, including loading and error handling.
- Added search functionality for delivery notes, enabling operators to filter by delivery note number, customer PO, and order details.
- Updated the UI to display delivery notes alongside items, improving the overall user experience in the deliveries management interface.
- Enhanced tests to cover new delivery notes functionality and ensure correct behavior across various scenarios.
2026-08-17 01:27:03 +02:00

628 lines
22 KiB
TypeScript

/**
* Unit tests for deliveries-service.
*
* We test the service in isolation — PocketBase is mocked
* because the real one requires network + admin creds. The
* mock is intentionally minimal: only `pb.collection().update`
* and `pb.collection().getFullList` / `getList` are exercised.
* The service's contract is:
*
* - `loadItems` only flips `invoiced → with_driver` (others
* are skipped, never silently written)
* - `checkInItems` only flips `with_driver → checked_in` and
* requires a non-empty reference (1..64 chars)
* - Both are idempotent on retry: an item already in the
* target state is skipped, not double-stamped
* - Both report per-item failures instead of aborting the
* batch on the first error
*
* The PB SDK shape used in the mock is the small surface the
* service actually depends on. If a future refactor pulls in
* more SDK calls, the mock will need extending.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
import { getAdminPocketBase } from "./pocketbase-admin";
import { loadItems, checkInItems, listDeliveryItems, markSignedCopyReceived, markDeliveryNoteLoaded } from "./deliveries-service";
type MockPB = {
collection: (name: string) => {
getFullList: (opts?: unknown) => Promise<Array<{ id: string; item_status: string }>>;
/**
* Matches the PB SDK signature: `getList(page, perPage, opts)`.
* The 1-arg mock we had before worked for the original tests
* because they didn't inspect the filter; the new tests do,
* so we now accept the full 3-arg shape and capture `opts`
* in a closure so the test can read the filter string.
*/
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; expand?: string; fields?: string }
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
};
};
// Suppress the "unused parameter" lint for the SDK-shape args
// (page, perPage, opts) — the original mock didn't take them
// and we kept the 1-arg shape for clarity. They're typed
// correctly so a refactor that needs them will see the shape.
void (null as unknown as Parameters<MockPB["collection"]>[0]);
function makeMockPB(rows: Array<{ id: string; item_status: string }>): {
pb: MockPB;
updates: Array<{ id: string; patch: Record<string, unknown> }>;
} {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const pb: MockPB = {
collection: () => ({
getFullList: async () => rows,
getList: async () => ({
items: rows.map((r) => ({ ...r })),
totalItems: rows.length,
page: 1,
perPage: 200,
totalPages: 1,
}),
update: async (id, patch) => {
updates.push({ id, patch });
return { id, ...patch };
},
}),
};
return { pb, updates };
}
/**
* Test-only mock that captures the filter string passed to
* `getList` and returns whatever items the test wants. Each
* call site makes its own — the captured state is per-test.
*/
function makeCapturingMock(items: Array<Record<string, unknown>>) {
const filters: string[] = [];
const pb: MockPB = {
collection: () => ({
getFullList: async () => [],
getList: async (page, perPage, opts) => {
// We don't read page / perPage in the captured state —
// they're declared so the SDK signature is honoured. The
// `void` calls silence the unused-arg lint without
// touching the test logic.
void page;
void perPage;
filters.push(opts?.filter ?? "");
return {
items,
totalItems: items.length,
page: 1,
perPage: 200,
totalPages: 1,
};
},
update: async () => ({}),
}),
};
return {
pb,
/** Read all filter strings from the most recent fetch. */
getFilters: () => filters.slice(),
/** Read the filter string from the most recent getList call. */
getLastFilter: () => filters[filters.length - 1] ?? "",
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("loadItems", () => {
it("flips invoiced items to with_driver + stamps loaded_at", async () => {
const { pb, updates } = makeMockPB([
{ id: "a", item_status: "invoiced" },
{ id: "b", item_status: "invoiced" },
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await loadItems(["a", "b"]);
expect(result.loaded).toEqual(["a", "b"]);
expect(result.skipped).toEqual([]);
expect(updates).toHaveLength(2);
for (const u of updates) {
expect(u.patch).toMatchObject({ item_status: "with_driver" });
expect(typeof u.patch.loaded_at).toBe("string");
}
});
it("skips items that are not in 'invoiced' state (idempotent on retry)", async () => {
const { pb, updates } = makeMockPB([
{ id: "a", item_status: "invoiced" },
{ id: "b", item_status: "with_driver" }, // already loaded
{ id: "c", item_status: "checked_in" }, // already verified
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await loadItems(["a", "b", "c"]);
expect(result.loaded).toEqual(["a"]);
expect(result.skipped.map((s) => s.id)).toEqual(["b", "c"]);
expect(result.skipped[0].reason).toMatch(/with_driver/);
expect(result.skipped[1].reason).toMatch(/checked_in/);
expect(updates).toHaveLength(1);
});
it("rejects empty itemIds with a clear error", async () => {
await expect(loadItems([])).resolves.toEqual({ loaded: [], skipped: [] });
});
it("rejects batches over the 200-item cap", async () => {
await expect(
loadItems(Array.from({ length: 201 }, (_, i) => `id${i}`)),
).rejects.toThrow(/Too many items/);
});
});
describe("checkInItems", () => {
it("flips with_driver items to checked_in + writes the reference", async () => {
const { pb, updates } = makeMockPB([
{ id: "a", item_status: "with_driver" },
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await checkInItems({
itemIds: ["a"],
reference: "INV-12345",
checkedInByUserId: "user1234567890ab",
});
expect(result.checkedIn).toEqual(["a"]);
expect(result.skipped).toEqual([]);
expect(updates).toHaveLength(1);
expect(updates[0].patch).toMatchObject({
item_status: "checked_in",
delivery_reference: "INV-12345",
checked_in_by: "user1234567890ab",
});
expect(typeof updates[0].patch.checked_in_at).toBe("string");
});
it("trims the reference before writing", async () => {
const { pb, updates } = makeMockPB([
{ id: "a", item_status: "with_driver" },
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
await checkInItems({
itemIds: ["a"],
reference: " INV-12345 ",
checkedInByUserId: "user1234567890ab",
});
expect(updates[0].patch.delivery_reference).toBe("INV-12345");
});
it("rejects empty references (whole batch fails)", async () => {
const { pb } = makeMockPB([
{ id: "a", item_status: "with_driver" },
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
await expect(
checkInItems({
itemIds: ["a"],
reference: " ",
checkedInByUserId: "user1234567890ab",
}),
).rejects.toThrow(/empty/);
});
it("rejects references over 64 chars", async () => {
await expect(
checkInItems({
itemIds: ["a"],
reference: "x".repeat(65),
checkedInByUserId: "user1234567890ab",
}),
).rejects.toThrow(/too long/);
});
it("skips items in unexpected source state (idempotent on retry)", async () => {
const { pb, updates } = makeMockPB([
{ id: "a", item_status: "with_driver" },
{ id: "b", item_status: "invoiced" }, // not loaded yet
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await checkInItems({
itemIds: ["a", "b"],
reference: "INV-1",
checkedInByUserId: "user1234567890ab",
});
expect(result.checkedIn).toEqual(["a"]);
expect(result.skipped.map((s) => s.id)).toEqual(["b"]);
expect(updates).toHaveLength(1);
});
});
describe("listDeliveryItems", () => {
it("calls PB with the right filter for each tab", async () => {
const { pb } = makeMockPB([]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
// We can't directly assert on the filter string without
// extending the mock, but we can confirm the call goes
// through and returns the rows we seeded. The filter
// string is unit-tested implicitly via the expand+fields
// path (assertion: it doesn't throw).
await expect(
listDeliveryItems({ tab: "to_load" }),
).resolves.toMatchObject({ totalItems: 0 });
await expect(
listDeliveryItems({ tab: "back_from_driver" }),
).resolves.toMatchObject({ totalItems: 0 });
await expect(
listDeliveryItems({ tab: "checked_in" }),
).resolves.toMatchObject({ totalItems: 0 });
});
it("extracts the QB invoice number from the order_item_details JSON", async () => {
const items = [
{
id: "a",
description: "M10 Bolt",
quantity: 50,
selling_price: 1.2,
cost_price: 0.8,
item_status: "invoiced",
delivery_reference: null,
order_item_details: {
invoice: { number: "INV-99999", date: "2026-07-20" },
print: { count: 1 },
},
loaded_at: null,
checked_in_at: null,
checked_in_by: null,
order: "order1",
updated: "2026-07-20T00:00:00Z",
expand: {
order: {
id: "order1",
order_number: "ORD-2026-07-001",
company: "co1",
customer: "cu1",
expand: {
customer: { id: "cu1", customer: "Acme" },
company: { id: "co1", company: "Acme Holdings" },
},
},
},
},
];
const { pb } = makeCapturingMock(items);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await listDeliveryItems({ tab: "to_load" });
expect(result.items[0].invoice_number).toBe("INV-99999");
expect(result.items[0].order?.order_number).toBe("ORD-2026-07-001");
expect(result.items[0].order?.customer_name).toBe("Acme");
});
it("returns null invoice_number when order_item_details is missing or malformed", async () => {
const items = [
// Missing JSON
{
id: "a", description: "x", item_status: "invoiced",
selling_price: null, cost_price: null, delivery_reference: null,
loaded_at: null, checked_in_at: null, checked_in_by: null,
order: null, updated: "", order_item_details: null,
expand: {},
},
// Empty JSON
{
id: "b", description: "y", item_status: "invoiced",
selling_price: null, cost_price: null, delivery_reference: null,
loaded_at: null, checked_in_at: null, checked_in_by: null,
order: null, updated: "", order_item_details: {},
expand: {},
},
// invoice object with no number
{
id: "c", description: "z", item_status: "invoiced",
selling_price: null, cost_price: null, delivery_reference: null,
loaded_at: null, checked_in_at: null, checked_in_by: null,
order: null, updated: "", order_item_details: { invoice: {} },
expand: {},
},
];
const { pb } = makeCapturingMock(items);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await listDeliveryItems({ tab: "to_load" });
expect(result.items.map((i) => i.invoice_number)).toEqual([null, null, null]);
});
it("builds an items filter that hits description, invoice number, and prices", async () => {
// The service runs two queries per fetch (items + delivery_notes);
// we want to assert against the items filter, which is the first
// one captured.
const { pb, getFilters } = makeCapturingMock([]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
// Text-only query — should hit description and the JSON-path
// invoice number. (delivery_reference was removed: the remote
// PB rejects `delivery_reference ~ "..."` with 400 because the
// field doesn't exist in the live schema, even though the
// response shape includes it as null.)
await listDeliveryItems({ tab: "to_load", search: "INV" });
const itemsFilterFirst = getFilters()[0];
expect(itemsFilterFirst).toContain('description ~ "INV"');
expect(itemsFilterFirst).toContain('order_item_details.invoice.number ~ "INV"');
expect(itemsFilterFirst).not.toContain("delivery_reference ~");
// Not numeric — no price clauses.
expect(itemsFilterFirst).not.toContain("selling_price =");
expect(itemsFilterFirst).not.toContain("cost_price =");
// Numeric query — should ALSO add exact price matches.
await listDeliveryItems({ tab: "to_load", search: "120" });
const itemsFilterSecond = getFilters()[2];
expect(itemsFilterSecond).toContain('selling_price = 120');
expect(itemsFilterSecond).toContain('cost_price = 120');
// Decimal query — still triggers the price branch.
await listDeliveryItems({ tab: "to_load", search: "1.20" });
const itemsFilterThird = getFilters()[4];
expect(itemsFilterThird).toContain('selling_price = 1.20');
// Mixed alpha+numeric — does NOT trigger the price branch.
await listDeliveryItems({ tab: "to_load", search: "120 boxes" });
const itemsFilterFourth = getFilters()[6];
expect(itemsFilterFourth).not.toContain("selling_price =");
});
it("builds a delivery-notes filter that hits delivery_note_number and customer_po", async () => {
const { pb, getFilters } = makeCapturingMock([]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
// The DN filter is the second one captured per fetch.
await listDeliveryItems({ tab: "to_load", search: "DN26" });
const dnFilter = getFilters()[1];
expect(dnFilter).toContain('delivery_note_number ~ "DN26"');
expect(dnFilter).toContain('customer_po ~ "DN26"');
// The DN filter does NOT touch the items fields.
expect(dnFilter).not.toContain("description ~");
expect(dnFilter).not.toContain("delivery_reference ~");
});
it("threads the per-tab status filter into the delivery-notes query", async () => {
const { pb, getFilters } = makeCapturingMock([]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
// To Load surfaces paper on the desk that hasn't been loaded
// for the driver yet — draft + sent.
await listDeliveryItems({ tab: "to_load" });
const toLoadDnFilter = getFilters()[1];
expect(toLoadDnFilter).toContain('status = "draft"');
expect(toLoadDnFilter).toContain('status = "sent"');
expect(toLoadDnFilter).not.toContain('"loaded"');
expect(toLoadDnFilter).not.toContain('"po_received"');
expect(toLoadDnFilter).not.toContain('"invoiced"');
expect(toLoadDnFilter).not.toContain('"completed"');
expect(toLoadDnFilter).not.toContain('"cancelled"');
// Back from Driver is paper that's with the driver — loaded
// (on the way out) or po_received (on the way back).
await listDeliveryItems({ tab: "back_from_driver" });
const backFromDriverDnFilter = getFilters()[3];
expect(backFromDriverDnFilter).toContain('status = "loaded"');
expect(backFromDriverDnFilter).toContain('status = "po_received"');
await listDeliveryItems({ tab: "checked_in" });
expect(getFilters()[5]).toContain('status = "invoiced"');
});
it("escapes quotes in the search string", async () => {
const { pb, getFilters } = makeCapturingMock([]);
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
await listDeliveryItems({ tab: "to_load", search: 'INV "123"' });
// The embedded quote in "123" should be backslash-escaped
// so the filter string stays valid.
expect(getFilters()[0]).toContain('INV \\"123\\"');
});
});
type MockPBWithGetOne = {
collection: (name: string) => {
getFullList: (opts?: unknown) => Promise<Array<Record<string, unknown>>>;
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; expand?: string; fields?: string }
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
getOne: (id: string) => Promise<Record<string, unknown> | null>;
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
};
};
function makeDnMockPB(dn: Record<string, unknown>): {
pb: MockPBWithGetOne;
updates: Array<{ id: string; patch: Record<string, unknown> }>;
} {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const pb: MockPBWithGetOne = {
collection: () => ({
getFullList: async () => [],
getList: async () => ({
items: [],
totalItems: 0,
page: 1,
perPage: 200,
totalPages: 1,
}),
getOne: async (id) => (id === dn.id ? dn : null),
update: async (id, patch) => {
updates.push({ id, patch });
return { ...dn, ...patch };
},
}),
};
return { pb, updates };
}
describe("markSignedCopyReceived", () => {
const fakeFile = { name: "signed-copy.pdf" } as unknown as File;
it("flips sent → completed + stamps signed_copy in one update", async () => {
const { pb, updates } = makeDnMockPB({
id: "dn1",
status: "sent",
signed_copy: [],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await markSignedCopyReceived({
deliveryNoteId: "dn1",
file: fakeFile,
});
expect(result.status).toBe("completed");
expect(result.deliveryNoteId).toBe("dn1");
expect(updates).toHaveLength(1);
expect(updates[0].patch).toMatchObject({
status: "completed",
});
expect(Array.isArray(updates[0].patch.signed_copy)).toBe(true);
});
it("accepts any non-cancelled status as source state (per operator request)", async () => {
// The operator asked for signed-copy upload to be available
// regardless of the status. Cancelled is still rejected (it's
// an abandoned DN), but every other lifecycle state works.
for (const status of ["draft", "sent", "loaded", "po_received", "invoiced"]) {
const { pb, updates } = makeDnMockPB({ id: "dn1", status });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await markSignedCopyReceived({
deliveryNoteId: "dn1",
file: fakeFile,
});
expect(result.status).toBe("completed");
expect(updates).toHaveLength(1);
}
});
it("is idempotent on a completed DN (no second update, no error)", async () => {
const { pb, updates } = makeDnMockPB({
id: "dn1",
status: "completed",
signed_copy: ["existing.pdf"],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await markSignedCopyReceived({
deliveryNoteId: "dn1",
file: fakeFile,
});
expect(result.status).toBe("completed");
expect(result.filename).toBe("existing.pdf");
expect(updates).toHaveLength(0);
});
it("rejects cancelled DNs but accepts missing-id rejection", async () => {
// Cancelled is the only DN status the signed-copy upload rejects.
const { pb, updates } = makeDnMockPB({ id: "dn1", status: "cancelled" });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
await expect(
markSignedCopyReceived({
deliveryNoteId: "dn1",
file: fakeFile,
}),
).rejects.toThrow(/cancelled/);
expect(updates).toHaveLength(0);
// Missing DN — getOne returns null in the mock, which the
// service's `getOne` call rejects (PB SDK throws on a 404).
const { pb: pb2, updates: updates2 } = makeDnMockPB({
id: "other",
status: "sent",
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb2 as never);
await expect(
markSignedCopyReceived({
deliveryNoteId: "dn1",
file: fakeFile,
}),
).rejects.toThrow();
expect(updates2).toHaveLength(0);
});
});
describe("markDeliveryNoteLoaded", () => {
it("flips draft → loaded", async () => {
const { pb, updates } = makeDnMockPB({ id: "dn1", status: "draft" });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const updated = await markDeliveryNoteLoaded("dn1");
expect(updated.status).toBe("loaded");
expect(updates).toHaveLength(1);
expect(updates[0].patch).toMatchObject({ status: "loaded" });
});
it("flips sent → loaded", async () => {
const { pb, updates } = makeDnMockPB({ id: "dn1", status: "sent" });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const updated = await markDeliveryNoteLoaded("dn1");
expect(updated.status).toBe("loaded");
expect(updates).toHaveLength(1);
});
it("is idempotent on loaded (no second update fires)", async () => {
const { pb, updates } = makeDnMockPB({ id: "dn1", status: "loaded" });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const updated = await markDeliveryNoteLoaded("dn1");
expect(updated.status).toBe("loaded");
// Idempotent path — no PATCH. The PB `updated` autodate is
// already a sufficient audit signal.
expect(updates).toHaveLength(0);
});
it("rejects po_received, invoiced, completed, and cancelled DNs", async () => {
for (const status of ["po_received", "invoiced", "completed", "cancelled"]) {
const { pb, updates } = makeDnMockPB({ id: "dn1", status });
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
await expect(
markDeliveryNoteLoaded("dn1"),
).rejects.toThrow(new RegExp(status));
expect(updates).toHaveLength(0);
}
});
});