The account-sale picker filtered on three customer columns — name,
number, email. `company` on a customer row is a RELATION id, not a
name, so it could never be text-matched; the company name was only ever
resolved afterwards by `expand` for display. Typing "Sibanye" returned
nothing even though 52 people work there.
- the filter now traverses the relation: `company.company ~ q`.
Verified live: "Sibanye - Rustenburg" 0 → 52, "Adal" 0 → 2,
"Alano" 0 → 1, while "Botha" and "Smit" are unchanged at 6 and 4.
- each row carries `viaCompany`, and name matches sort above
company-only matches. PB's own sort is by name across the whole
result set, which would interleave the two bands.
- /customers returns { items, total, shown } and the till renders a
count header: "52 matches", or "Showing 100 of 122 — keep typing to
narrow". The old `slice(0, 8)` showed 8 of 52 people with nothing
to suggest 44 were missing; 15 companies have more staff than that.
The picker click handler bound to every `div` in the results box, so
adding a count header would have made it selectable and picked a
customer with an empty id. Now scoped to `.cust-row`.
Two bugs the tests caught rather than the code:
- the display cap was first set to 50 while its own comment claimed
it sat above the largest company — which has 52 staff. That
silently re-created the truncation this change removes. Now 100,
with a test asserting a 52-person company comes back whole.
- PocketBase has no escape for a literal backslash: `\'` is
accepted, but one, two or three backslashes all fail to parse and
500 the request, so typing `\` in the search box errored. No
escaping survives, so backslashes are stripped before the filter
(`M20\Nut` searches `M20Nut`) and a backslash-only query returns
nothing rather than matching every customer.
13 new tests cover the company clause, the 52-staff case, the cap,
ranking order, backslash stripping and the empty-after-strip case.
Verified live against PocketBase across 10 queries — the ranking
guarantee held on every one.
sw.js v33 -> v34: pos-app.js and pos.html are cache-first in the shell.
185 lines
7 KiB
TypeScript
185 lines
7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { searchCustomersForPos } from "./pos-service";
|
|
import type { PosPb } from "./pos-service";
|
|
|
|
/**
|
|
* Customer picker for account sales.
|
|
*
|
|
* The search used to be three clauses over `customer`, `number` and
|
|
* `email`, with a silent `slice(0, 8)` on the result. Two consequences:
|
|
* a company name found nothing, and a 52-person company rendered 8 rows
|
|
* that read as the complete team.
|
|
*/
|
|
|
|
type CustomerRow = {
|
|
id: string;
|
|
customer: string;
|
|
number?: string;
|
|
email?: string;
|
|
company?: string;
|
|
expand?: { company?: { company?: string } };
|
|
};
|
|
|
|
const companyId = (name: string) => `co-${name.toLowerCase().replace(/\W+/g, "-")}`;
|
|
|
|
/** Records the filter it was handed, and returns rows matching the "own fields" OR a company name. */
|
|
function stubPb(rows: CustomerRow[]) {
|
|
const seen: string[] = [];
|
|
const pb = {
|
|
collection: (name: string) => ({
|
|
getFullList: async (opts: Record<string, unknown> = {}) => {
|
|
expect(name).toBe("customers");
|
|
const filter = String(opts.filter ?? "");
|
|
seen.push(filter);
|
|
// Emulate PB well enough to exercise the service's own logic:
|
|
// the service re-derives `viaCompany` in JS, so we only need to
|
|
// hand back every row and let the service do the filtering it can.
|
|
// Filter here the way PB would, so the ranking bands are real.
|
|
const m = filter.match(/~ '((?:[^'\\]|\\.)*)'/);
|
|
const needle = m ? m[1].replace(/\\(.)/g, "$1").toLowerCase() : "";
|
|
return rows.filter((r) => {
|
|
const own = [r.customer, r.number, r.email].some((v) =>
|
|
String(v ?? "").toLowerCase().includes(needle),
|
|
);
|
|
const co = r.expand?.company?.company ?? "";
|
|
return own || co.toLowerCase().includes(needle);
|
|
});
|
|
},
|
|
}),
|
|
} as unknown as PosPb;
|
|
return { pb, seen };
|
|
}
|
|
|
|
const row = (
|
|
id: string,
|
|
name: string,
|
|
companyName?: string,
|
|
extra: Partial<CustomerRow> = {},
|
|
): CustomerRow => ({
|
|
id,
|
|
customer: name,
|
|
company: companyName ? companyId(companyName) : "",
|
|
expand: companyName ? { company: { company: companyName } } : undefined,
|
|
...extra,
|
|
});
|
|
|
|
// Names deliberately do NOT contain "Sibanye" — a staff member called
|
|
// "Sibanye Worker" would match on their OWN name, which is the whole point
|
|
// of `viaCompany`.
|
|
const SIBANYE_STAFF = Array.from({ length: 52 }, (_, i) =>
|
|
row(`s${i}`, `Rustenburg Employee ${String(i).padStart(2, "0")}`, "Sibanye - Rustenburg Platinum Mines"),
|
|
);
|
|
|
|
describe("searchCustomersForPos — matches the company name", () => {
|
|
it("includes company.company in the filter", async () => {
|
|
const { pb, seen } = stubPb(SIBANYE_STAFF);
|
|
await searchCustomersForPos(pb, "Sibanye");
|
|
expect(seen[0]).toContain("company.company ~");
|
|
});
|
|
|
|
it("returns the whole company, not the first 8", async () => {
|
|
const { pb } = stubPb(SIBANYE_STAFF);
|
|
const res = await searchCustomersForPos(pb, "Sibanye");
|
|
expect(res.total).toBe(52);
|
|
expect(res.shown).toBe(52);
|
|
expect(res.items).toHaveLength(52);
|
|
});
|
|
|
|
it("caps ABOVE the largest company, so a real one is never cut", async () => {
|
|
// The cap was briefly 50 while the biggest company has 52 staff, which
|
|
// silently re-created the bug it was meant to fix.
|
|
const { pb } = stubPb(SIBANYE_STAFF);
|
|
const res = await searchCustomersForPos(pb, "Sibanye");
|
|
expect(res.shown).toBe(res.total);
|
|
});
|
|
|
|
it("marks company-only matches so the till can rank them last", async () => {
|
|
const { pb } = stubPb([
|
|
...SIBANYE_STAFF,
|
|
row("named", "Johan Sibanye", "Somewhere Else"), // name contains the query
|
|
]);
|
|
const res = await searchCustomersForPos(pb, "Sibanye");
|
|
expect(res.items.find((c) => c.id === "named")?.viaCompany).toBe(false);
|
|
expect(res.items.find((c) => c.id === "s0")?.viaCompany).toBe(true);
|
|
});
|
|
|
|
it("ranks a name match above a company-only match", async () => {
|
|
const { pb } = stubPb([row("s0", "Almo Adams", "Sibanye"), row("direct", "Zack Sibanye")]);
|
|
const res = await searchCustomersForPos(pb, "Sibanye");
|
|
expect(res.items[0].id).toBe("direct");
|
|
expect(res.items[0].viaCompany).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("searchCustomersForPos — never truncates silently", () => {
|
|
it("caps the display but still reports the true total", async () => {
|
|
const many = Array.from({ length: 300 }, (_, i) => row(`c${i}`, `Person ${i}`, "Big Co"));
|
|
const { pb } = stubPb(many);
|
|
const res = await searchCustomersForPos(pb, "Big Co");
|
|
expect(res.total).toBe(300);
|
|
expect(res.shown).toBeLessThan(res.total);
|
|
expect(res.items).toHaveLength(res.shown);
|
|
});
|
|
|
|
it("reports total === shown when nothing was cut", async () => {
|
|
const { pb } = stubPb(SIBANYE_STAFF);
|
|
const res = await searchCustomersForPos(pb, "Sibanye");
|
|
expect(res.total).toBe(res.shown);
|
|
});
|
|
});
|
|
|
|
describe("searchCustomersForPos — input handling", () => {
|
|
it("returns an empty shape for a blank query, without hitting PB", async () => {
|
|
const { pb } = stubPb(SIBANYE_STAFF);
|
|
for (const q of ["", " "]) {
|
|
expect(await searchCustomersForPos(pb, q)).toEqual({ items: [], total: 0, shown: 0 });
|
|
}
|
|
});
|
|
|
|
it("strips backslashes, which used to throw a 500", async () => {
|
|
const { pb, seen } = stubPb([row("a", "Backslash Co")]);
|
|
// PB has no escape for a literal backslash — 1, 2 or 3 all fail to
|
|
// parse — so it must never reach the filter at all.
|
|
await searchCustomersForPos(pb, "M20\\Nut");
|
|
expect(seen[0]).not.toContain("\\");
|
|
expect(seen[0]).toContain("M20Nut");
|
|
});
|
|
|
|
it("treats a backslash-only query as blank, not as 'everyone'", async () => {
|
|
// Stripping leaves an empty needle, and `~ ''` matches every customer —
|
|
// a lone backslash must not dump all 703 rows into the picker.
|
|
const { pb, seen } = stubPb(SIBANYE_STAFF);
|
|
expect(await searchCustomersForPos(pb, "\\")).toEqual({ items: [], total: 0, shown: 0 });
|
|
expect(seen, "must not query PB with an empty needle").toHaveLength(0);
|
|
});
|
|
|
|
it("escapes apostrophes", async () => {
|
|
const { pb, seen } = stubPb([row("a", "Al's Plant")]);
|
|
const res = await searchCustomersForPos(pb, "Al's");
|
|
expect(seen[0]).toContain("Al\\'s");
|
|
expect(res.total).toBe(1);
|
|
});
|
|
|
|
it("falls back through name → email → number when the name is blank", async () => {
|
|
const { pb } = stubPb([
|
|
{ id: "e1", customer: "", email: "no-name@example.com", expand: undefined },
|
|
]);
|
|
const res = await searchCustomersForPos(pb, "example.com");
|
|
expect(res.items[0].name).toBe("no-name@example.com");
|
|
});
|
|
|
|
it("renders an em-dash placeholder when a matched row has no name at all", async () => {
|
|
// Reachable in practice: a customer linked to a company but with a
|
|
// blank name, matched through its company name.
|
|
const { pb } = stubPb([
|
|
{
|
|
id: "z",
|
|
customer: "",
|
|
expand: { company: { company: "Zebra Mine" } },
|
|
},
|
|
]);
|
|
const res = await searchCustomersForPos(pb, "Zebra");
|
|
expect(res.total).toBe(1);
|
|
expect(res.items[0].name).toBe("—");
|
|
});
|
|
});
|