wacrm/src/lib/contacts/dedupe.test.ts
Arnas Donauskas 3dce1b56cd
feat(contacts): prevent duplicate phone numbers on create/import (#212)
The same phone could become multiple contacts: only the WhatsApp
webhook de-duped, while manual create and CSV import inserted raw
un-normalized phones, and the DB had no uniqueness. That fragmented
conversations, deals, and tags across duplicates.

- Migration 022: generated `contacts.phone_normalized` (digits-only),
  a one-shot `merge_duplicate_contacts()` that collapses existing
  dupes into the oldest row (re-pointing conversations, deals, notes,
  tags, custom values, broadcast recipients — no data loss), then a
  UNIQUE (account_id, phone_normalized) index as the authoritative
  guarantee for every write path.
- Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix
  prefilter + phonesMatch), isExactMatch, isUniqueViolation,
  dedupeByPhone. The webhook now reuses findExistingContact (and
  recovers from a unique-violation race instead of dropping the
  message).
- Contact form: on-blur duplicate check — hard-block + "view existing"
  link on an exact match, soft warning on a trunk-variant near-match;
  unique-violation backstop on submit.
- CSV import: de-dupes within the file and against existing contacts,
  reports "imported / skipped / failed", treats 23505 as skipped.
- Contact type gains account_id (was missing) + phone_normalized.
- Unit tests for the dedupe helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:52:38 +03:00

98 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
import {
dedupeByPhone,
findExistingContact,
isExactMatch,
isUniqueViolation,
normalizeKey,
} from "./dedupe";
describe("normalizeKey", () => {
it("strips every non-digit", () => {
expect(normalizeKey("+1 (555) 123-4567")).toBe("15551234567");
expect(normalizeKey("15551234567")).toBe("15551234567");
});
it("collapses different formats of the same number to one key", () => {
expect(normalizeKey("+44 7911 123456")).toBe(normalizeKey("447911123456"));
});
});
describe("isExactMatch", () => {
it("treats different formatting of the same digits as exact", () => {
expect(isExactMatch({ id: "1", phone: "+1 555-123-4567" }, "15551234567")).toBe(
true,
);
});
it("is false for a trunk-variant (fuzzy) match", () => {
// last-8 match but not the same full number
expect(isExactMatch({ id: "1", phone: "37063949836" }, "370063949836")).toBe(
false,
);
});
});
describe("isUniqueViolation", () => {
it("detects Postgres 23505", () => {
expect(isUniqueViolation({ code: "23505" })).toBe(true);
});
it("is false for other errors / non-objects", () => {
expect(isUniqueViolation({ code: "23502" })).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation("boom")).toBe(false);
});
});
describe("dedupeByPhone", () => {
it("keeps the first occurrence and counts in-file duplicates", () => {
const { unique, duplicates } = dedupeByPhone([
{ phone: "+1 555-1111", name: "A" },
{ phone: "15551111", name: "B" }, // same digits as #1
{ phone: "+1 555-2222", name: "C" },
]);
expect(unique.map((r) => r.name)).toEqual(["A", "C"]);
expect(duplicates).toBe(1);
});
it("drops rows with no digits", () => {
const { unique, duplicates } = dedupeByPhone([
{ phone: " " },
{ phone: "+1 555-3333" },
]);
expect(unique).toHaveLength(1);
expect(duplicates).toBe(1);
});
});
describe("findExistingContact", () => {
// Minimal SupabaseClient stub: resolves the .from().select().eq().like()
// chain to a fixed candidate set.
function stubDb(rows: Array<{ id: string; phone: string }>): SupabaseClient {
const builder = {
select: () => builder,
eq: () => builder,
like: () => Promise.resolve({ data: rows, error: null }),
};
return { from: () => builder } as unknown as SupabaseClient;
}
it("returns a trunk-variant match via phonesMatch", async () => {
const db = stubDb([{ id: "c1", phone: "37063949836" }]);
const hit = await findExistingContact(db, "acct", "+370 063 949 836");
expect(hit?.id).toBe("c1");
});
it("returns null when no candidate matches", async () => {
const db = stubDb([{ id: "c1", phone: "15559999999" }]);
const hit = await findExistingContact(db, "acct", "+1 555-123-4567");
expect(hit).toBeNull();
});
it("returns null for an empty phone without querying", async () => {
const db = stubDb([{ id: "c1", phone: "15551234567" }]);
expect(await findExistingContact(db, "acct", " ")).toBeNull();
});
});