211 lines
7.9 KiB
TypeScript
211 lines
7.9 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";
|
|
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:_$!<Home>!$_\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: "_$!<Home>!$_" });
|
|
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:_$!<Home>!$_");
|
|
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");
|
|
});
|
|
});
|