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); }); });