WFDASH-WEBAPP/src/lib/pb-helpers-duplicate.test.ts
SJCoetz 7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00

181 lines
6 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* Unit tests for the duplicate-detection helpers added 2026-07-22.
*
* The helpers are split into two layers:
* 1. A pure fetch that pulls a small page of records whose `field`
* matches the trimmed value as a case-insensitive substring
* (via PB's `~` operator), then post-filters to an exact
* case-insensitive trimmed match.
* 2. The three public entry points — `findCompanyDuplicates`,
* `findSupplierDuplicates`, `findCustomerDuplicates` — that wire
* the right collection/field for each entity, and add the extra
* multi-field gate (name + company, plus optional email) for
* customers.
*
* These tests stub PocketBase at the `getPocketBase` boundary and
* assert on the filter string and the post-filtered result.
*/
const { getListMock, collectionMock } = vi.hoisted(() => {
const getListMock = vi.fn();
const collectionMock = vi.fn(() => ({ getList: getListMock }));
return { getListMock, collectionMock };
});
vi.mock("./pocketbase", () => ({
__esModule: true,
default: () => ({
collection: collectionMock,
}),
}));
import {
findCompanyDuplicates,
findCustomerDuplicates,
findSupplierDuplicates,
} from "./pb-helpers";
describe("findCompanyDuplicates", () => {
beforeEach(() => {
getListMock.mockReset();
collectionMock.mockClear();
});
it("returns an empty list when the value is blank", async () => {
const result = await findCompanyDuplicates(" ");
expect(result).toEqual([]);
expect(getListMock).not.toHaveBeenCalled();
});
it("queries PB with a case-insensitive substring filter and post-filters to exact match", async () => {
// PB returns both a substring match ("Acme") and a non-match
// ("Acme Subsidiary"). The helper should keep only the exact
// case-insensitive trimmed match ("ACME").
getListMock.mockResolvedValueOnce({
items: [
{ id: "c1", company: "Acme" },
{ id: "c2", company: "Acme Subsidiary" },
{ id: "c3", company: "ACME" },
],
});
const result = await findCompanyDuplicates("acme");
expect(getListMock).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({ filter: 'company ~ "acme"' })
);
expect(result.map((c) => c.id)).toEqual(["c1", "c3"]);
});
it("appends `id != ...` to the filter when excludeId is set", async () => {
getListMock.mockResolvedValueOnce({ items: [] });
await findCompanyDuplicates("Acme", "co-self");
expect(getListMock).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: 'company ~ "Acme" && id != "co-self"',
})
);
});
it("escapes double quotes in the value so PB doesn't blow up", async () => {
getListMock.mockResolvedValueOnce({ items: [] });
await findCompanyDuplicates('Acme "Pty" Ltd');
expect(getListMock).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: 'company ~ "Acme \\"Pty\\" Ltd"',
})
);
});
});
describe("findSupplierDuplicates", () => {
beforeEach(() => {
getListMock.mockReset();
collectionMock.mockClear();
});
it("queries the suppliers collection by suppliername", async () => {
getListMock.mockResolvedValueOnce({
items: [{ id: "s1", suppliername: "West Rand" }],
});
const result = await findSupplierDuplicates("WEST RAND", "s-self");
expect(collectionMock).toHaveBeenCalledWith("suppliers");
expect(getListMock).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: 'suppliername ~ "WEST RAND" && id != "s-self"',
})
);
expect(result).toHaveLength(1);
});
});
describe("findCustomerDuplicates", () => {
beforeEach(() => {
getListMock.mockReset();
collectionMock.mockClear();
});
it("requires the same (name, company) pair to match", async () => {
// Two customers with the same name "John Smith" — one at Acme,
// one at MineCo. The form is filling out (John Smith, Acme),
// so only the Acme row should be a duplicate.
getListMock.mockResolvedValueOnce({
items: [
{ id: "cu1", customer: "John Smith", company: "acme-id", email: "" },
{ id: "cu2", customer: "John Smith", company: "mineco-id", email: "" },
],
});
const result = await findCustomerDuplicates("john smith", "acme-id", "", "self-id");
expect(collectionMock).toHaveBeenCalledWith("customers");
expect(getListMock).toHaveBeenCalledWith(
1,
50,
expect.objectContaining({
filter: 'customer ~ "john smith" && id != "self-id"',
expand: "company",
})
);
expect(result.map((c) => c.id)).toEqual(["cu1"]);
});
it("adds an email-gate when both records have an email set", async () => {
// Same name + same company, but different emails — these are
// genuinely two different people at the same company. The
// email-gate kicks in only when both sides have an email.
getListMock.mockResolvedValueOnce({
items: [
{ id: "cu1", customer: "John Smith", company: "acme-id", email: "john@acme.com" },
{ id: "cu2", customer: "John Smith", company: "acme-id", email: "different@acme.com" },
],
});
const result = await findCustomerDuplicates(
"John Smith",
"acme-id",
"john@acme.com",
"self-id"
);
// cu1 matches (name+company, emails equal), cu2 doesn't (different email).
expect(result.map((c) => c.id)).toEqual(["cu1"]);
});
it("skips the email gate when the form's email is blank", async () => {
// Form has no email, but the existing record does. We should
// still match on (name, company) alone — otherwise the form
// would miss every customer record that's missing an email.
getListMock.mockResolvedValueOnce({
items: [
{ id: "cu1", customer: "John Smith", company: "acme-id", email: "john@acme.com" },
],
});
const result = await findCustomerDuplicates("John Smith", "acme-id", "", "self-id");
expect(result).toHaveLength(1);
});
});