WFDASH-WEBAPP/src/lib/server/stock-pick-service.test.ts
Dave 980f40aa79 feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search
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.
2026-10-04 20:40:13 +02:00

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(
[],
);
});
});