/** * 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>; /** * 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>; totalItems: number; page: number; perPage: number; totalPages: number; }>; update: (id: string, patch: Record) => Promise; }; }; // 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[0]); function makeMockPB(rows: Array<{ id: string; item_status: string }>): { pb: MockPB; updates: Array<{ id: string; patch: Record }>; } { const updates: Array<{ id: string; patch: Record }> = []; 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>) { 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>>; getList: ( page?: number, perPage?: number, opts?: { filter?: string; expand?: string; fields?: string } ) => Promise<{ items: Array>; totalItems: number; page: number; perPage: number; totalPages: number; }>; getOne: (id: string) => Promise | null>; update: (id: string, patch: Record) => Promise; }; }; function makeDnMockPB(dn: Record): { pb: MockPBWithGetOne; updates: Array<{ id: string; patch: Record }>; } { const updates: Array<{ id: string; patch: Record }> = []; 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); } }); });