Reported again by the submitter's colleague at LINET after #223 was closed: duplicate checking was implemented for vCard and never for LDIF, so re-importing an address book still leaves a second copy of everything. That was deliberate at the time -- the matching key was an open question I did not want to answer alone -- but the answer had already been given on #174 and I closed the issue without acting on it. The answer, in the submitter's words: an attribute that *can* change is fine, because it will not have changed between two imports minutes apart. An import is not a sync. That makes the `dn` usable -- it is the only identity the file carries, and Mozilla's schema defines no UID -- and it needs no guessing at all, unlike the name-plus-email fallback I had been weighing. So `uidFromDn` derives a namespaced, stable uid from the distinguished name, normalised for the case and spacing two exports of one directory differ in. A card the book already holds under that uid is updated rather than duplicated, merged the way the vCard import merges: what the file carries wins, what it does not mention is left alone. Reported as created and updated, which is the pair that was asked for. Three things worth knowing: Matching is per address book, so two customer directories that each hold a `cn=John Smith` stay two people as long as they are filed separately. Imported into one book they would merge, which is the one way this can be wrong and the reason the escape hatch is worth naming. The look-alike count stays, and now means something narrower: entries that `dn` matching could not catch -- one whose `dn` moved between exports, and anything imported before there was a `dn` to match on. Those are still only counted, never merged. A file holding two entries under one `dn` is malformed, since a directory cannot, and now becomes one card instead of two sharing an identity. FEATURES gains the re-import behaviour for both formats; it documented neither.
151 lines
7.3 KiB
TypeScript
151 lines
7.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CAP, client } from "@/jmap/client";
|
|
import { useContacts } from "@/store/contacts";
|
|
import type { ContactCard, JmapSession } from "@/jmap/types";
|
|
|
|
/*
|
|
* Counting look-alikes on an LDIF import, without acting on them.
|
|
*
|
|
* Mozilla's schema has no UID, so the import invents one and a re-import
|
|
* duplicates everything. Whether to guess an identity from a name and an
|
|
* address is still open on #223 -- and the harm that was actually reported was
|
|
* confusion rather than duplication: somebody imports a file twice and cannot
|
|
* tell what happened. So this counts and says so, and imports every card
|
|
* regardless. Reporting is not matching.
|
|
*/
|
|
|
|
interface SetArgs { create?: Record<string, Record<string, unknown>> }
|
|
|
|
function server(existing: Array<Partial<ContactCard> & { id: string }>) {
|
|
const sets: SetArgs[] = [];
|
|
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
|
if (name === "ContactCard/query") {
|
|
const position = (args.position as number) ?? 0;
|
|
return [name, { accountId: "a1", queryState: "1", canCalculateChanges: false, position, ids: position ? [] : existing.map((c) => c.id), total: existing.length }, id];
|
|
}
|
|
if (name === "ContactCard/get") {
|
|
const want = new Set((args.ids as string[]) ?? []);
|
|
return [name, { accountId: "a1", state: "1", list: existing.filter((c) => want.has(c.id)), notFound: [] }, id];
|
|
}
|
|
if (name === "ContactCard/set") {
|
|
sets.push({ create: args.create as Record<string, Record<string, unknown>> });
|
|
return [name, {
|
|
accountId: "a1", oldState: "1", newState: "2",
|
|
created: Object.fromEntries(Object.keys((args.create ?? {}) as object).map((k) => [k, { id: `new-${k}` }])),
|
|
notCreated: {},
|
|
}, id];
|
|
}
|
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
|
});
|
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return sets;
|
|
}
|
|
|
|
/** A card already in the book, with the two fields likeness is read from. */
|
|
const card = (id: string, full: string, ...emails: string[]) => ({
|
|
id, uid: `uid-${id}`, addressBookIds: { book1: true },
|
|
name: { full },
|
|
emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])),
|
|
}) as unknown as Partial<ContactCard> & { id: string };
|
|
|
|
const entry = (cn: string, mail: string) =>
|
|
`dn: cn=${cn}\ngivenName: ${cn.split(" ")[0]}\nsn: ${cn.split(" ").slice(-1)[0]}\ncn: ${cn}\nmail: ${mail}\n`;
|
|
|
|
beforeEach(() => {
|
|
client.session = {
|
|
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
|
accounts: {}, primaryAccounts: {}, state: "s1",
|
|
} as unknown as JmapSession;
|
|
useContacts.setState({ accountId: "a1", available: true, books: {}, cards: {} as Record<string, ContactCard> });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("telling somebody what an LDIF re-import duplicated", () => {
|
|
it("counts an entry that matches an existing card on name and address", async () => {
|
|
server([card("c1", "Jane Doe", "[email protected]")]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r).toEqual({ created: 1, updated: 0, alike: 1 });
|
|
});
|
|
|
|
it("imports it anyway, which is the whole point of counting rather than matching", async () => {
|
|
const sets = server([card("c1", "Jane Doe", "[email protected]")]);
|
|
await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(Object.keys(sets[0]!.create!)).toHaveLength(1);
|
|
});
|
|
|
|
it("does not count a name match with a different address", async () => {
|
|
// Two people who share a name are two people. This is exactly the guess
|
|
// the counting refuses to make on anyone's behalf.
|
|
server([card("c1", "Jane Doe", "[email protected]")]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r.alike).toBe(0);
|
|
});
|
|
|
|
it("does not count an address match under a different name", async () => {
|
|
server([card("c1", "Someone Else", "[email protected]")]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r.alike).toBe(0);
|
|
});
|
|
|
|
it("recognises a match on a second address", async () => {
|
|
server([card("c1", "Jane Doe", "[email protected]", "[email protected]")]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r.alike).toBe(1);
|
|
});
|
|
|
|
it("ignores case and spacing, which an export and a hand-typed card differ in", async () => {
|
|
server([card("c1", " JANE DOE ", "[email protected]")]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r.alike).toBe(1);
|
|
});
|
|
|
|
it("counts each entry once however many of its addresses match", async () => {
|
|
server([card("c1", "Jane Doe", "[email protected]", "[email protected]")]);
|
|
const two = `dn: cn=Jane Doe\ncn: Jane Doe\nmail: [email protected]\nmozillaSecondEmail: [email protected]\n`;
|
|
const r = await useContacts.getState().importLdif(two, "book1");
|
|
expect(r.alike).toBe(1);
|
|
});
|
|
|
|
it("looks only at the book being imported into", async () => {
|
|
const elsewhere = { ...card("c1", "Jane Doe", "[email protected]"), addressBookIds: { book2: true } };
|
|
server([elsewhere]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r.alike).toBe(0);
|
|
});
|
|
|
|
it("counts nothing against an empty book", async () => {
|
|
server([]);
|
|
const r = await useContacts.getState().importLdif(entry("Jane Doe", "[email protected]"), "book1");
|
|
expect(r).toEqual({ created: 1, updated: 0, alike: 0 });
|
|
});
|
|
|
|
it("does not count the file against itself", async () => {
|
|
// Two people in one file are two new cards, neither a duplicate of
|
|
// something that was already here. The scan is read before anything lands.
|
|
server([]);
|
|
const two = entry("Jane Doe", "[email protected]") + "\n" + entry("Alan Turing", "[email protected]");
|
|
const r = await useContacts.getState().importLdif(two, "book1");
|
|
expect(r).toEqual({ created: 2, updated: 0, alike: 0 });
|
|
});
|
|
|
|
it("makes one card of two entries in a file that share a dn", async () => {
|
|
// A directory cannot hold two entries under one name, so a file that does
|
|
// is malformed -- and must not produce two cards sharing an identity,
|
|
// which is the duplication this all exists to prevent. The later wins.
|
|
const sets = server([]);
|
|
const twice = entry("Jane Doe", "[email protected]") + "\n" + entry("Jane Doe", "[email protected]");
|
|
const r = await useContacts.getState().importLdif(twice, "book1");
|
|
expect(r).toEqual({ created: 1, updated: 0, alike: 0 });
|
|
const only = Object.values(sets[0]!.create!)[0]!;
|
|
expect(Object.values(only.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
|
});
|
|
});
|