Say how much an LDIF re-import duplicated, without acting on it
The half of #223 that can move while the matching question is still open. Mozilla's schema defines no UID, so the import invents one and a re-import duplicates everything. Whether to guess an identity from a name and an address instead is the reporter's call and he has not made it -- but the harm that was actually reported was confusion rather than duplication: somebody imports a file twice and cannot tell what happened. So the import now counts how many of the entries look like contacts the book already held, and says so in a second message. Every card is still imported. Nothing is skipped and nothing is merged, which is the point: counting is a different act from matching, and it takes no decision away from the person who still owes us one. The likeness key is name plus one address, and it is wrong in both directions by design -- two colleagues sharing a name and an alias collapse, somebody whose address changed since the last export looks like a stranger. That is tolerable for a number on a toast and would not be tolerable for a merge, which is exactly why the number is all it does. The scan the vCard import already makes for UIDs now collects names and addresses on the same request, so this costs no extra round trip. It is read before anything is created, so a file that repeats a person twice counts as two new cards rather than as a duplicate of itself. If the answer comes back "match on name and email", the matching is written and becomes a skip instead of a count. Refs #223.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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, skipped: 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, skipped: 0, alike: 0 });
|
||||
});
|
||||
|
||||
it("does not count the file against itself", async () => {
|
||||
// Two of the same person in one file are two new cards, not a duplicate of
|
||||
// something that was already here. The scan is read before anything lands.
|
||||
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: 2, skipped: 0, alike: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user