Files
ihasmail-inbuxa/web/src/store/__tests__/ics-import.test.ts
T
jcoffey-dev 8173e22ccb Update a re-imported event rather than skipping it
Contacts and calendars disagreed on a re-import: a vCard or LDIF entry
whose identity a book already held overwrote the card there (#242, #274),
while an event whose UID a calendar held was counted and thrown away
(#222). The asymmetry was never decided -- it was where each half stopped.

Decided on #279: calendars update too, with two properties held back.
`participants` carries every attendee's accepted/declined and
`recurrenceOverrides` holds every "just this Wednesday" edit made here.
Both are decisions taken after the file was written, and a file that
mentions them at all describes them as they were at export, so writing
either one over would destroy work silently and return no error. A
corrected export now fixes the time, the title and the location, and
leaves who said yes alone. `uid` is held back with them: it is what the
two were matched on, so it is already equal.

The scan returns uid -> id rather than a set of UIDs, since updating
needs something to address, and creates and updates now share one
`maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new
and 300 changed batched separately would be two calls of 300, neither
over a ceiling of 500 and both refused. Counts become created/updated,
reported as the contacts import reports them.

Still no scheduling messages, on an update as much as on a create. That
is a real cost -- an event a re-import moves is moved here and nowhere
else -- and it is the lesser one: an import is not the place to start
mailing a room full of people who never asked for it.

Driven against the mock end to end: a second file with the same UID
updated the event in place, took the file's title, start and location,
and left an accepted RSVP and a per-occurrence override untouched even
though the file carried participants of its own.
2026-09-07 12:56:36 -07:00

373 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useCalendar } from "@/store/calendar";
import type { JmapSession, UploadResponse } from "@/jmap/types";
/**
* Importing a file is not importing an invitation, and the difference is the
* count: an emailed invite carries one event, an export carries a year of them.
* These pin the three things that follow from that -- as few round trips as the
* server will take, none of them over the ceiling it will refuse the whole call
* for, and nothing of where the events came from riding along into the calendar
* they land in.
*/
/** What the server hands back for a two-event file. Ids and the JMAP-only
* bookkeeping are there because a real parse includes them, and dropping them
* is the store's job. */
const PARSED = [
{
"@type": "Event", id: "srv1", uid: "[email protected]", title: "Kickoff",
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
calendarIds: { somewhere: true }, baseEventId: "b1", utcStart: "2026-09-02T09:00:00Z",
utcEnd: "2026-09-02T10:00:00Z", isOrigin: true, method: "REQUEST",
},
{
"@type": "Event", id: "srv2", title: "Retro (no uid)",
start: "2026-09-09T09:00:00", duration: "PT30M", timeZone: "Etc/UTC",
},
];
interface SetArgs { create?: Record<string, Record<string, unknown>>; update?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
/**
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
* than an array is the single-event shape, which Stalwart also returns.
* @param notCreated refusals to hand back instead of creations.
* @param notUpdated refusals to hand back instead of updates.
* @param max the ceiling on objects in one call, refused the way Stalwart
* refuses it: the whole call, creating nothing.
* @param failOn which `/set` call (0-based) answers with an error instead.
*/
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; notUpdated?: Record<string, unknown>; max?: number; failOn?: number; existing?: Array<{ id: string; uid: string; calendarIds: Record<string, boolean> }> } = {}) {
const sets: SetArgs[] = [];
const existing = opts.existing ?? [];
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 === "CalendarEvent/parse") {
const blobIds = args.blobIds as string[];
return [name, { accountId: "a1", parsed: parsed === null ? {} : { [blobIds[0]!]: parsed }, notParsable: [] }, id];
}
if (name === "CalendarEvent/set") {
const nth = sets.length;
sets.push({
create: args.create as Record<string, Record<string, unknown>>,
update: args.update as Record<string, Record<string, unknown>>,
sendSchedulingMessages: args.sendSchedulingMessages as boolean,
});
const keys = Object.keys((args.create ?? {}) as object);
const patched = Object.keys((args.update ?? {}) as object);
/* Whole-call refusals, both of them: nothing in this call gets written.
Creates and updates count against the ceiling together, which is why
the store batches them together. */
if (opts.max != null && keys.length + patched.length > opts.max) {
return ["error", { type: "requestTooLarge", description: "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call." }, id];
}
if (opts.failOn === nth) return ["error", { type: "serverFail", description: "the roof fell in" }, id];
const notCreated = opts.notCreated ?? {};
const notUpdated = opts.notUpdated ?? {};
return [name, {
accountId: "a1", oldState: "1", newState: "2",
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
notCreated,
updated: Object.fromEntries(patched.filter((k) => !(k in notUpdated)).map((k) => [k, null])),
notUpdated,
}, id];
}
// The scan for UIDs already in the calendar: a query for the account's
// events, then their uid and calendarIds.
if (name === "CalendarEvent/query") {
const position = (args.position as number) ?? 0;
return [name, { accountId: "a1", queryState: "1", canCalculateChanges: false, position, ids: position ? [] : existing.map((e) => e.id), total: existing.length }, id];
}
if (name === "CalendarEvent/get") {
const want = new Set((args.ids as string[]) ?? []);
return [name, { accountId: "a1", state: "1", list: existing.filter((e) => want.has(e.id)), notFound: [] }, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return sets;
}
let uploaded: { type?: string; text: string } | null = null;
/** Two tests stand a mock in for it; put the store's own back afterwards. */
const realInvalidate = useCalendar.getState().invalidate;
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
accounts: {}, primaryAccounts: {}, state: "s1",
} as unknown as JmapSession;
useCalendar.setState({ accountId: "a1", available: true, calendars: {}, events: {}, ranges: {}, invalidate: realInvalidate });
uploaded = null;
// XHR, not fetch, so it is stubbed at the client rather than at the network.
// jsdom's Blob has no `text()`, hence the reader.
const readBlob = (b: Blob) => new Promise<string>((resolve) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.readAsText(b);
});
vi.spyOn(client, "upload").mockImplementation(async (_acc, data, opts) => {
uploaded = { type: opts?.type, text: await readBlob(data as Blob) };
return { accountId: "a1", blobId: "blob1", type: "text/calendar", size: 1 } as UploadResponse;
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("importing an .ics file", () => {
it("uploads the file as calendar data", async () => {
server(PARSED);
await useCalendar.getState().importIcs("BEGIN:VCALENDAR\nEND:VCALENDAR\n", "cal1");
expect(uploaded?.type).toBe("text/calendar");
expect(uploaded?.text).toContain("BEGIN:VCALENDAR");
});
it("creates every event in one call when the file fits in one, not one call each", async () => {
const sets = server(PARSED);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 2, updated: 0 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toEqual(["e0", "e1"]);
});
it("files them into the calendar that was picked", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
for (const e of Object.values(sets[0]!.create!)) {
expect(e.calendarIds).toEqual({ cal1: true });
}
});
it("leaves behind everything that belonged to where the events came from", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
const first = sets[0]!.create!.e0!;
for (const gone of ["id", "baseEventId", "utcStart", "utcEnd", "isOrigin", "method"]) {
expect(first, gone).not.toHaveProperty(gone);
}
expect(first.title).toBe("Kickoff");
expect(first.start).toBe("2026-09-02T09:00:00");
});
it("keeps the file's own uid, and invents one only where there is none", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.create!.e0!.uid).toBe("[email protected]");
expect(sets[0]!.create!.e1!.uid).toEqual(expect.any(String));
expect(sets[0]!.create!.e1!.uid).not.toBe("");
});
it("does not mail the participants of an event being filed", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.sendSchedulingMessages).toBe(false);
});
it("takes a single event, which is what a one-event file parses to", async () => {
const sets = server(PARSED[0]);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 1, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toEqual(["e0"]);
});
it("says a file held no events rather than reporting none imported", async () => {
server(null);
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/no events in it/);
});
it("reports the server's refusal when nothing was accepted", async () => {
server(PARSED, { notCreated: { e0: { type: "invalidProperties", description: "start is required" }, e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/start is required/);
});
it("counts what got in when only some of it did", async () => {
server(PARSED, { notCreated: { e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 0 });
});
});
/*
* A real export, rather than the two-event file above.
*
* `CalendarEvent/set` is refused whole over `maxObjectsInSet` -- the server
* does not take the first 500 and drop the rest, it creates nothing and answers
* `requestTooLarge` -- so a file large enough to cross the ceiling used to
* import no events at all. The server here refuses the same way, which is what
* makes these more than an assertion about call counts.
*/
describe("importing a file bigger than the server will take at once", () => {
const MAX = 500;
const many = (n: number) =>
Array.from({ length: n }, (_, i) => ({
"@type": "Event", uid: `uid-${i}@example.org`, title: `Event ${i}`,
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
}));
it("splits it into calls the server will accept, and files all of it", async () => {
const sets = server(many(1200), { max: MAX });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1200, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([500, 500, 200]);
});
it("splits by what the session advertises, not by a number of its own", async () => {
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
const sets = server(many(100), { max: 40 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 100, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([40, 40, 20]);
});
it("keeps every event distinct across the split", async () => {
const sets = server(many(600), { max: MAX });
await useCalendar.getState().importIcs("x", "cal1");
const uids = sets.flatMap((s) => Object.values(s.create!).map((e) => e.uid));
expect(new Set(uids).size).toBe(600);
expect(uids).toContain("[email protected]");
expect(uids).toContain("[email protected]");
});
it("re-reads the calendar once, not once per batch", async () => {
server(many(1200), { max: MAX });
const invalidate = vi.fn();
useCalendar.setState({ invalidate });
await useCalendar.getState().importIcs("x", "cal1");
expect(invalidate).toHaveBeenCalledTimes(1);
});
it("says how much got in when a later batch fails, rather than only that it failed", async () => {
server(many(1200), { max: MAX, failOn: 2 });
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/1000 of 1200/);
});
it("leaves what did get in visible when a later batch fails", async () => {
server(many(1200), { max: MAX, failOn: 2 });
const invalidate = vi.fn();
useCalendar.setState({ invalidate });
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow();
expect(invalidate).toHaveBeenCalledTimes(1);
});
it("passes the server's own words through when the very first batch fails", async () => {
server(many(1200), { max: MAX, failOn: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/roof fell in/);
});
});
/*
* Re-importing the same file.
*
* The import kept the file's own UID from the day it was written, which is the
* whole of what is needed to recognise an event that is already here -- and
* nothing looked. Importing an export twice left second copies of everything,
* which the reporter's colleague hit during testing (#173, decided there:
* "duplicate checks on UIDs if UID present in event"). Issue #222 made that a
* skip; #279 made it an update, because the reason to import a file a second
* time is usually that the first one was not right.
*/
describe("re-importing events the calendar already has", () => {
const here = (uid: string, calendarId = "cal1") => ({ id: `srv-${uid}`, uid, calendarIds: { [calendarId]: true } });
it("updates an event whose uid is already in this calendar", async () => {
const sets = server(PARSED, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 1 });
// Only the second event, which has no uid of its own, is new.
expect(Object.values(sets[0]!.create!).map((e) => e.title)).toEqual(["Retro (no uid)"]);
// The update is addressed to the event that is here, not to the file's id.
expect(Object.keys(sets[0]!.update!)).toEqual(["[email protected]"]);
expect(sets[0]!.update!["[email protected]"]!.title).toBe("Kickoff");
});
it("holds back the answers and the per-occurrence edits, which live on the event", async () => {
/*
* The one thing #279 turned on. `participants` carries who accepted and
* `recurrenceOverrides` carries every "just this Wednesday" change made
* here; a file describes both as they were at export, so writing either one
* over throws away work with no error anywhere. Everything else in the file
* wins, which is the point of importing it again.
*/
const withPeople = [{
...PARSED[0],
title: "Kickoff (moved)",
participants: { "[email protected]": { "@type": "Participant", participationStatus: "needs-action" } },
recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Skip" } },
}];
const sets = server(withPeople, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 1 });
const patch = sets[0]!.update!["[email protected]"]!;
expect(patch.title).toBe("Kickoff (moved)");
expect(patch).not.toHaveProperty("participants");
expect(patch).not.toHaveProperty("recurrenceOverrides");
// The identity the two were matched on is not re-asserted as a field.
expect(patch).not.toHaveProperty("uid");
});
it("does not mail anyone about an event it updated", async () => {
// Filing a file is not scheduling, on an update as much as on a create.
const sets = server(PARSED, { existing: [here("[email protected]")] });
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.sendSchedulingMessages).toBe(false);
});
it("imports an event whose uid is in a different calendar", async () => {
// A UID is what makes an event the same event *across* calendars, so the
// same event legitimately being in two of them is not a duplicate.
const sets = server(PARSED, { existing: [here("[email protected]", "cal2")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 2, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toHaveLength(2);
});
it("imports an event that arrived with no uid, rather than guessing", async () => {
const sets = server(PARSED, { existing: [here("[email protected]")] });
await useCalendar.getState().importIcs("x", "cal1");
expect(Object.values(sets[0]!.create!)[0]!.uid).toEqual(expect.any(String));
});
it("updates the lot when the whole file is already here, creating nothing", async () => {
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
const sets = server(both, { existing: [here("[email protected]"), here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 2 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toHaveLength(0);
expect(Object.keys(sets[0]!.update!)).toHaveLength(2);
});
it("reports a refusal to update, rather than reporting nothing imported", async () => {
// Everything in the file is already here, so an update is the whole of the
// import -- and a refusal of it is the only thing there is to say.
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
server(both, {
existing: [here("[email protected]"), here("[email protected]")],
notUpdated: {
"[email protected]": { type: "forbidden", description: "the calendar is read-only" },
"[email protected]": { type: "forbidden" },
},
});
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/read-only/);
});
it("splits creates and updates against one ceiling, not one each", async () => {
/*
* Stalwart counts every object in a `/set` against `maxObjectsInSet`
* together and refuses the whole call over it. 300 new and 300 changed
* batched separately would be two calls of 300 -- neither over 500, both
* refused.
*/
const MAX = 500;
const file = Array.from({ length: 600 }, (_, i) => ({
"@type": "Event", uid: `uid-${i}@example.org`, title: `Event ${i}`,
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
}));
const existing = Array.from({ length: 300 }, (_, i) => here(`uid-${i}@example.org`));
const sets = server(file, { max: MAX, existing });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 300, updated: 300 });
expect(sets.map((s) => Object.keys(s.create ?? {}).length + Object.keys(s.update ?? {}).length)).toEqual([500, 100]);
});
});