Files
contact-tools/tests/contact/model.test.ts
T
2026-09-01 13:04:50 +02:00

135 lines
4.7 KiB
TypeScript

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