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.
129 lines
5.2 KiB
TypeScript
129 lines
5.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CAP, client } from "@/jmap/client";
|
|
import { useContacts } from "@/store/contacts";
|
|
import type { JmapSession } from "@/jmap/types";
|
|
|
|
/**
|
|
* The store half of LDIF import: everything that happens after the file has
|
|
* been read. Reading it is `parseLdif` and `cardFromLdif`, tested next door.
|
|
*/
|
|
|
|
const TWO = `dn: cn=Jane Doe
|
|
givenName: Jane
|
|
sn: Doe
|
|
cn: Jane Doe
|
|
mail: [email protected]
|
|
|
|
dn: cn=Alan Turing
|
|
givenName: Alan
|
|
sn: Turing
|
|
cn: Alan Turing
|
|
mail: [email protected]
|
|
`;
|
|
|
|
interface SetArgs { create?: Record<string, Record<string, unknown>> }
|
|
|
|
function server(opts: { notCreated?: Record<string, unknown> } = {}) {
|
|
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/set") {
|
|
sets.push({ create: args.create as Record<string, Record<string, unknown>> });
|
|
const keys = Object.keys((args.create ?? {}) as object);
|
|
const notCreated = opts.notCreated ?? {};
|
|
return [name, {
|
|
accountId: "a1", oldState: "1", newState: "2",
|
|
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).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;
|
|
}
|
|
|
|
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: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("importing an LDIF address book", () => {
|
|
it("creates every entry in one call, not one call each", async () => {
|
|
const sets = server();
|
|
const n = await useContacts.getState().importLdif(TWO, "book1");
|
|
expect(n).toEqual({ created: 2, updated: 0, alike: 0 });
|
|
expect(sets).toHaveLength(1);
|
|
expect(Object.keys(sets[0]!.create!)).toEqual(["c0", "c1"]);
|
|
});
|
|
|
|
it("files them into the address book that was picked", async () => {
|
|
const sets = server();
|
|
await useContacts.getState().importLdif(TWO, "book1");
|
|
for (const c of Object.values(sets[0]!.create!)) {
|
|
expect(c.addressBookIds).toEqual({ book1: true });
|
|
}
|
|
});
|
|
|
|
it("sends finished cards, since no server parses LDIF", async () => {
|
|
const sets = server();
|
|
await useContacts.getState().importLdif(TWO, "book1");
|
|
const first = sets[0]!.create!.c0!;
|
|
expect(first["@type"]).toBe("Card");
|
|
expect(first.version).toBe("1.0");
|
|
expect(first.kind).toBe("individual");
|
|
expect(first.name).toMatchObject({ full: "Jane Doe" });
|
|
});
|
|
|
|
it("gives each contact an identity derived from its entry, and a distinct one", async () => {
|
|
const sets = server();
|
|
await useContacts.getState().importLdif(TWO, "book1");
|
|
const uids = Object.values(sets[0]!.create!).map((c) => c.uid as string);
|
|
expect(new Set(uids).size).toBe(2);
|
|
// Namespaced, so it is never mistaken for a UID a vCard author meant, and
|
|
// stable, so importing the same file again recognises these.
|
|
expect(uids.every((u) => u.startsWith("urn:x-ihasmail:ldif:"))).toBe(true);
|
|
});
|
|
|
|
it("gives an entry with no usable dn an identity of its own", async () => {
|
|
const sets = server();
|
|
await useContacts.getState().importLdif("dn:\ncn: Nameless Place\nmail: [email protected]\n", "book1");
|
|
const uid = Object.values(sets[0]!.create!)[0]!.uid as string;
|
|
expect(uid).not.toContain("urn:x-ihasmail:ldif:");
|
|
expect(uid.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("says a file held no contacts rather than reporting none imported", async () => {
|
|
server();
|
|
await expect(useContacts.getState().importLdif("not an address book\n", "book1")).rejects.toThrow(/no contacts in it/);
|
|
});
|
|
|
|
it("skips entries too empty to be a person, and imports the rest", async () => {
|
|
const sets = server();
|
|
const n = await useContacts.getState().importLdif(`${TWO}\ndn: cn=Nobody\nobjectClass: top\n`, "book1");
|
|
expect(n).toEqual({ created: 2, updated: 0, alike: 0 });
|
|
expect(Object.keys(sets[0]!.create!)).toHaveLength(2);
|
|
});
|
|
|
|
it("reports the server's refusal when nothing was accepted", async () => {
|
|
server({ notCreated: { c0: { type: "invalidProperties", description: "name is required" }, c1: { type: "invalidProperties" } } });
|
|
await expect(useContacts.getState().importLdif(TWO, "book1")).rejects.toThrow(/name is required/);
|
|
});
|
|
|
|
it("counts what got in when only some of it did", async () => {
|
|
server({ notCreated: { c1: { type: "invalidProperties" } } });
|
|
await expect(useContacts.getState().importLdif(TWO, "book1")).resolves.toEqual({ created: 1, updated: 0, alike: 0 });
|
|
});
|
|
});
|