Files
ihasmail-inbuxa/web/src/store/__tests__/ldif-import.test.ts
T
jcoffey-dev 9f4bd65fd5 Update a contact on re-import rather than skipping it
#228 skipped a vCard whose UID the book already held. The reporter asked for
the opposite on #174 and he is right: the reason to import a file a second
time is usually that the first one was not right, so skipping means a
corrected export corrects nothing.

A merge, not a replacement. Properties the file carries overwrite what is
here; properties it does not mention are left alone, so a phone number added
in ihasmail after the first import survives a re-import of the original file.
The cost is that a field genuinely deleted at the source stays here, which is
the better way to be wrong -- the other way round loses work nobody asked to
lose. Worth confirming with him rather than assuming.

`addressBookIds` is left off the patch. The card is already in this book, so
saying it again says nothing, and saying it on a card that is also in another
book would move it.

Creates and updates now share one batch budget. Stalwart counts every object
in a /set together, so batching the halves separately would send 300 new and
300 changed as two calls of 300 and be refused for a limit of 500 that neither
half exceeds.

LDIF is untouched and still reports look-alikes without acting on them, since
what it should match on is the question still open on #223. Both imports keep
one answer shape so a caller need not know which it called; LDIF's `updated`
is always 0, which is the honest number rather than a missing field.

The message a vCard attached to a message shows changes with it: the newer
copy now wins instead of being dropped, so it says the contact was brought up
to date rather than that nothing was added.

Refs #223.
2026-09-02 15:06:04 -07:00

122 lines
4.9 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 of its own, not the entry's directory name", async () => {
const sets = server();
await useContacts.getState().importLdif(TWO, "book1");
const uids = Object.values(sets[0]!.create!).map((c) => c.uid as string);
expect(uids.every((u) => typeof u === "string" && u.length > 0)).toBe(true);
expect(new Set(uids).size).toBe(2);
// A distinguished name says where an entry sat in somebody else's
// directory, and must not become the contact's identity here.
expect(uids.some((u) => u.includes("cn="))).toBe(false);
});
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 });
});
});