import { describe, expect, it } from "vitest"; import { analyzeDuplicates, contactsFromCsv, contactsToCsv, findDuplicates, inspectCsv, mergeContacts, parseVCards, serializeVCards, } from "../../src/contact/model"; import { contactQrPayload } from "../../src/contact/qr"; const twoCards = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\nEMAIL;TYPE=HOME,PREF:ada@example.test\r\nNOTE:one\r\n two\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nFN:Ada L. Lovelace\r\nN:Lovelace;Ada;L.;;\r\nEMAIL;PREF=1:ADA@example.test\r\nEND:VCARD\r\n`; describe("vCard model", () => { it("unfolds and parses vCard 3 and 4 values", () => { const parsed = parseVCards(twoCards); expect(parsed.contacts).toHaveLength(2); expect(parsed.contacts[0]).toMatchObject({ version: "3.0", familyName: "Lovelace", givenName: "Ada", note: "onetwo", }); expect(parsed.contacts[0]?.emails[0]).toMatchObject({ value: "ada@example.test", preferred: true, }); }); it("serializes canonical CRLF cards and round trips supported data", () => { const contacts = parseVCards(twoCards).contacts; const output = serializeVCards(contacts, "4.0"); expect(output).toContain("VERSION:4.0\r\n"); expect(output).not.toContain("TEL"); expect(parseVCards(output).contacts[0]?.formattedName).toBe("Ada Lovelace"); }); it("rejects incomplete and oversized inputs", () => { expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u); expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow( /2 MiB/u, ); }); it("maps quoted CSV and exports it deterministically", () => { const csv = 'Full Name,Email,Notes\r\n"Doe, Jane",jane@example.test,"line 1\nline 2"\r\n'; const inspected = inspectCsv(csv); expect(inspected.inferred).toMatchObject({ formattedName: "Full Name", email: "Email", note: "Notes", }); const contacts = contactsFromCsv(csv, inspected.inferred); expect(contacts[0]?.formattedName).toBe("Doe, Jane"); expect(contactsToCsv(contacts)).toContain('"Doe, Jane"'); }); it("finds exact normalized duplicates and reports merge conflicts", () => { const contacts = parseVCards(twoCards).contacts; const candidates = findDuplicates(contacts); expect(candidates[0]).toMatchObject({ score: 70 }); const merged = mergeContacts(contacts[0]!, contacts[1]!); expect(merged.merged.emails).toHaveLength(1); expect(merged.conflicts.join(" ")).toMatch(/Name/u); }); it("folds long Unicode properties in linear-size UTF-8 chunks", () => { const contact = parseVCards(twoCards).contacts[0]!; const output = serializeVCards([{ ...contact, note: "é".repeat(10_000) }]); for (const line of output.split("\r\n").filter(Boolean)) expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75); }); it("bounds pathological duplicate groups and reports truncation", () => { const contact = parseVCards(twoCards).contacts[0]!; const report = analyzeDuplicates( Array.from({ length: 2_000 }, (_value, index) => ({ ...contact, id: `same-${index}`, })), ); expect(report).toMatchObject({ truncated: true, evaluatedPairs: 25_000, candidateLimit: 10_000, }); expect(report.candidates.length).toBeLessThanOrEqual(10_000); }); it("does not collapse punctuation-distinct emails or URLs during merge", () => { const left = parseVCards(twoCards).contacts[0]!; const right = { ...parseVCards(twoCards).contacts[1]!, emails: [ { value: "adalovelace@example.test", types: [], preferred: false }, ], urls: [ { value: "https://example.test/a-b", types: [], preferred: false }, ], }; const merged = mergeContacts( { ...left, emails: [ { value: "ada.lovelace@example.test", types: [], preferred: true }, ], urls: [ { value: "https://example.test/ab", types: [], preferred: true }, ], }, right, ).merged; expect(merged.emails).toHaveLength(2); expect(merged.urls).toHaveLength(2); }); it("mitigates spreadsheet formulas in CSV export", () => { const contact = parseVCards(twoCards).contacts[0]!; const output = contactsToCsv([ { ...contact, formattedName: '=HYPERLINK("bad")' }, ]); expect(output.split("\r\n")[1]).toContain("'=HYPERLINK"); }); it("keeps QR payloads below conservative version-40 M capacity", () => { const contact = parseVCards(twoCards).contacts[0]!; expect(() => contactQrPayload({ ...contact, note: "x".repeat(2_300) }), ).toThrow(/too large/u); }); });