- 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.
628 lines
22 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|