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"; import { contactsFromJCard, contactsFromJSContact, contactsToJCard, contactsToJSContact, } from "../../src/contact/interchange"; 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("decodes vCard 2.1 values and preserves unknown grouped properties", () => { const source = `BEGIN:VCARD\r\nVERSION:2.1\r\nFN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Andr=C3=A9=\r\n Example\r\nN:Example;André;;;\r\nitem1.EMAIL;TYPE=HOME:andre@example.test\r\nitem1.X-ABLabel:_$!!$_\r\nPHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q==\r\nEND:VCARD\r\n`; const parsed = parseVCards(source); expect(parsed.contacts[0]).toMatchObject({ version: "2.1", formattedName: "André Example", }); expect(parsed.contacts[0]?.emails[0]?.group).toBe("item1"); const label = parsed.contacts[0]?.preservedProperties.find( (property) => property.name === "X-ABLABEL", ); expect(label).toMatchObject({ group: "item1", rawValue: "_$!!$_" }); const photo = parsed.contacts[0]?.preservedProperties.find( (property) => property.name === "PHOTO", ); expect([...photo!.decodedBytes!]).toEqual([0xff, 0xd8, 0xff, 0xd9]); const output = serializeVCards(parsed.contacts, "2.1"); expect(output).toContain("item1.X-ABLABEL:_$!!$_"); expect(output).toContain("PHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q=="); expect(parseVCards(output).contacts[0]?.formattedName).toBe( "André Example", ); }); 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); }); it("maps broader RFC 6350 fields and preserves preference parameters", () => { const source = `BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Example Person\r\nN:Person;Example;;;\r\nKIND:individual\r\nNICKNAME:Ex,E.P.\r\nROLE:Architect\r\nANNIVERSARY:2020-02-03\r\nGENDER:F\r\nLANG;PREF=1:de\r\nTZ:Europe/Berlin\r\nGEO:geo:52.5,13.4\r\nIMPP;TYPE=work:xmpp:example@example.test\r\nRELATED;TYPE=friend:urn:uuid:friend\r\nMEMBER:urn:uuid:group-member\r\nPHOTO;VALUE=uri:https://example.test/photo.jpg\r\nEND:VCARD\r\n`; const contact = parseVCards(source).contacts[0]!; expect(contact).toMatchObject({ kind: "individual", nicknames: ["Ex", "E.P."], role: "Architect", anniversary: "2020-02-03", gender: "F", }); expect(contact.languages[0]).toMatchObject({ value: "de", preferred: true, }); expect(contact.media[0]).toMatchObject({ kind: "PHOTO" }); const roundTrip = parseVCards(serializeVCards([contact])).contacts[0]!; expect(roundTrip.instantMessaging[0]?.value).toBe( "xmpp:example@example.test", ); expect(roundTrip.members[0]?.value).toBe("urn:uuid:group-member"); }); it("round-trips structured jCard and focused JSContact cards", () => { const contact = parseVCards(twoCards).contacts[0]!; const jcard = contactsToJCard([contact]); expect(jcard[0]?.[0]).toBe("vcard"); expect(contactsFromJCard(jcard)[0]).toMatchObject({ formattedName: "Ada Lovelace", familyName: "Lovelace", givenName: "Ada", }); const jscontact = contactsToJSContact([contact]); expect(jscontact.data[0]).toMatchObject({ "@type": "Card", version: "1.0", }); const imported = contactsFromJSContact(jscontact.data)[0]!; expect(imported).toMatchObject({ formattedName: "Ada Lovelace", familyName: "Lovelace", givenName: "Ada", }); expect(imported.emails[0]?.value).toBe("ada@example.test"); }); });