The delivery-notes list was close to unusable: company groups initialised
to an empty Set, so `isOpen` was false for every group and the page landed
on a stack of company headers with nothing under them. The code comment
claimed the opposite of what the code did.
Screen
- Track *collapsed* group names instead of open ones. Open is now the
default with no initialisation, and it survives a list re-fetch.
- Rows expand to show the real line items (description, qty, supplier,
SKU, price, subtotal) via the existing /detail read model, cached after
first open. Ported from /quotes, which already had this affordance.
- Whole-row click removed so expanding can't be mistaken for opening; the
DN number and "Open full delivery note ->" navigate instead.
- Row tint as a derived progress ladder — no new stored statuses:
none = draft · yellow = signed copy · blue = quoted · green = po_received
`invoiced` leaves the Active tab, so Active is now genuinely what is
outstanding. Hovering the status badge explains the colour.
- DataTable gained an optional `selectionHeaderLabel`; the checkboxes
feed one action (Combine) and now say so.
Stock search
- The create-delivery-note dialog was `getFullList`-ing every stock item
with expand=supplier on each open, before a character was typed. Now a
debounced typeahead against /api/v1/stock-items/search.
- Deliberately NOT the existing quick-draft search route: that one is
served with an admin client and no session check, so its result isn't
gated on the listRule. The new route uses assertBrowserSession.
Fixes
- The PDF preview modal was clamped to 384px wide and 900px tall despite
passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a
*variant*, so twMerge kept both and the variant won on cascade order
(verified against the compiled stylesheet: 88,536 vs 258,517). Both
preview variants now repeat the `sm:` prefix so the conflict resolves.
- Expand-panel fetch was missing the Authorization header. Dashboard auth
lives in localStorage, so route handlers cannot read it from cookies and
the request 401'd. A 401 now reports an expired session instead of a
bare status code.
Notes
- 25 new unit tests. The stage ladder and the PB filter construction are
the two places where a silent wrong answer looks like a UI bug.
- Unauthenticated write routes on delivery-notes (create, combine,
create-quote, customer-po, mark-invoiced) have no session check. Left
as-is and reported separately — locking them is an auth-behaviour change.
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
escapePbFilterString,
|
|
searchStockItemsForPick,
|
|
STOCK_PICK_LIMIT_MAX,
|
|
} from "./stock-pick-service";
|
|
|
|
/**
|
|
* The service's whole job is building one PocketBase filter string.
|
|
* A malformed filter doesn't throw — PocketBase answers 400 and the
|
|
* operator sees an empty dropdown, which reads as "no stock matches"
|
|
* rather than "our query is broken". So the string itself is the
|
|
* thing worth asserting on.
|
|
*/
|
|
|
|
type Captured = { filter?: string; sort?: string; fields?: string; expand?: string };
|
|
|
|
function fakePb(items: Array<Record<string, unknown>> = []) {
|
|
const captured: Captured = {};
|
|
const getList = vi.fn(
|
|
async (
|
|
_page: number,
|
|
_perPage: number,
|
|
opts?: {
|
|
filter?: string;
|
|
sort?: string;
|
|
fields?: string;
|
|
expand?: string;
|
|
},
|
|
) => {
|
|
Object.assign(captured, opts);
|
|
return { items };
|
|
},
|
|
);
|
|
return {
|
|
captured,
|
|
pb: { collection: () => ({ getList }) },
|
|
getList,
|
|
};
|
|
}
|
|
|
|
describe("escapePbFilterString", () => {
|
|
it("neutralises a double quote so the filter literal can't be closed", () => {
|
|
// Without escaping, `q = 'x" || is_active=false || "'` rewrites the
|
|
// filter into something that returns a different (or no) result set.
|
|
expect(escapePbFilterString('x" || is_active=false || "')).toBe(
|
|
'x\\" || is_active=false || \\"',
|
|
);
|
|
});
|
|
|
|
it("escapes backslashes before quotes, in that order", () => {
|
|
// Order matters: escaping quotes first would double-escape the
|
|
// backslash that quote-escaping introduces.
|
|
expect(escapePbFilterString('a\\b"c')).toBe('a\\\\b\\"c');
|
|
});
|
|
});
|
|
|
|
describe("searchStockItemsForPick", () => {
|
|
it("searches sku, name AND description", () => {
|
|
// Description is included because the counter operator often
|
|
// searches for what a part does, not what it's called.
|
|
const { pb, captured } = fakePb();
|
|
void searchStockItemsForPick(pb, "nitrile", 10);
|
|
expect(captured.filter).toContain('sku ~ "nitrile"');
|
|
expect(captured.filter).toContain('name ~ "nitrile"');
|
|
expect(captured.filter).toContain('description ~ "nitrile"');
|
|
expect(captured.filter).toContain("is_active=true");
|
|
});
|
|
|
|
it("expands the supplier and projects only the columns the picker renders", () => {
|
|
const { pb, captured } = fakePb();
|
|
void searchStockItemsForPick(pb, "bolt", 10);
|
|
expect(captured.expand).toBe("supplier");
|
|
expect(captured.fields).toBe(
|
|
"id,sku,name,description,unit,current_qty,supplier",
|
|
);
|
|
});
|
|
|
|
it("clamps the limit to the documented max", () => {
|
|
const { pb, getList } = fakePb();
|
|
void searchStockItemsForPick(pb, "bolt", 9999);
|
|
expect(getList.mock.calls[0][1]).toBe(STOCK_PICK_LIMIT_MAX);
|
|
});
|
|
|
|
it("escapes a quote in the user query rather than emitting a broken filter", () => {
|
|
const { pb, captured } = fakePb();
|
|
void searchStockItemsForPick(pb, 'bo"lt', 10);
|
|
// Every quote inside the filter must be escaped — an odd count of
|
|
// raw quotes is exactly what produces the silent 400.
|
|
const raw = (captured.filter ?? "").match(/"/g) ?? [];
|
|
const escaped = (captured.filter ?? "").match(/\\"/g) ?? [];
|
|
expect(raw.length).toBeGreaterThan(0);
|
|
expect(escaped.length).toBeGreaterThan(0);
|
|
expect(captured.filter).toContain('bo\\"lt');
|
|
});
|
|
|
|
it("normalises a sparse row into a renderable shape", async () => {
|
|
// PB omits null fields rather than sending them. `current_qty` has
|
|
// to come back as a number or the dropdown renders "qty null".
|
|
const { pb } = fakePb([
|
|
{ id: "abc123", name: "Hex bolt M8" },
|
|
]);
|
|
const items = await searchStockItemsForPick(pb, "hex", 10);
|
|
expect(items[0]).toMatchObject({
|
|
id: "abc123",
|
|
name: "Hex bolt M8",
|
|
sku: "",
|
|
description: "",
|
|
current_qty: 0,
|
|
});
|
|
expect(items[0].unit).toBeUndefined();
|
|
expect(items[0].supplier).toBeUndefined();
|
|
expect(items[0].expand).toBeUndefined();
|
|
});
|
|
|
|
it("resolves the supplier name through the expand", async () => {
|
|
const { pb } = fakePb([
|
|
{
|
|
id: "abc123",
|
|
name: "Hex bolt M8",
|
|
sku: "BOLT-M8",
|
|
description: "Zinc plated",
|
|
unit: "EA",
|
|
current_qty: 12,
|
|
supplier: "sup1",
|
|
expand: { supplier: { suppliername: "Afrisan" } },
|
|
},
|
|
]);
|
|
const items = await searchStockItemsForPick(pb, "hex", 10);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({
|
|
id: "abc123",
|
|
sku: "BOLT-M8",
|
|
unit: "EA",
|
|
current_qty: 12,
|
|
supplier: "sup1",
|
|
});
|
|
expect(items[0].expand?.supplier?.suppliername).toBe("Afrisan");
|
|
});
|
|
|
|
it("coerces a null current_qty to 0 rather than leaking null", async () => {
|
|
const { pb } = fakePb([
|
|
{ id: "abc123", name: "Hex bolt M8", current_qty: null },
|
|
]);
|
|
const items = await searchStockItemsForPick(pb, "hex", 10);
|
|
expect(items[0].current_qty).toBe(0);
|
|
});
|
|
|
|
it("returns an empty array when PB returns nothing, not undefined", async () => {
|
|
const { pb } = fakePb([]);
|
|
await expect(searchStockItemsForPick(pb, "zzzz", 10)).resolves.toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|