Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.
A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.
`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.
The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.
Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.
Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
257 lines
12 KiB
TypeScript
257 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CAP, client } from "@/jmap/client";
|
|
import { CalendarSetError, eventIdForScope, isOccurrence, isThisAndFutureRefusal, occurrencePatch, OccurrenceScopeError, useCalendar } from "@/store/calendar";
|
|
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
|
|
|
/**
|
|
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
|
|
* refused one outright, so a mutation aimed at the wrong id of an expanded
|
|
* occurrence arrived as a toast rather than as data loss.
|
|
*
|
|
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
|
|
* destroy that meant the series removes one date and reports success, under a
|
|
* dialog that said "Delete all occurrences?". The resolution therefore lives in
|
|
* the store behind a required `scope`, and these tests are what stops it
|
|
* drifting back out to the callers.
|
|
*/
|
|
|
|
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
|
|
const OCCURRENCE: CalendarEvent = {
|
|
id: "iaaaaas",
|
|
baseEventId: "i",
|
|
"@type": "Event",
|
|
uid: "u1",
|
|
calendarIds: { c1: true },
|
|
start: "2026-09-02T09:00:00",
|
|
duration: "PT30M",
|
|
recurrenceId: "2026-09-02T09:00:00",
|
|
participants: {
|
|
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
|
|
},
|
|
} as unknown as CalendarEvent;
|
|
|
|
/** A one-off, which an expanded query still hands back with a base of its own. */
|
|
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
|
|
|
|
/** A master, fetched by id rather than expanded. */
|
|
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
|
|
|
|
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
|
|
|
|
/**
|
|
* A server that renumbers, the way 0.16.20 does.
|
|
*
|
|
* `resolvesTo` is the id the occurrence answers to *now* — deliberately not the
|
|
* id the cached object carries, because that is exactly the situation a write
|
|
* to the series leaves behind. A store that sends the id it was handed rather
|
|
* than the one it looked up will send `iaaaaas` and these tests will say so.
|
|
*/
|
|
function server(opts: { resolvesTo?: string | null } = {}) {
|
|
const calls: SetCall[] = [];
|
|
const resolved = opts.resolvesTo === undefined ? OCCURRENCE.id : opts.resolvesTo;
|
|
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/get" && id === "g") {
|
|
// The re-resolution lookup: same recurrenceId, whatever id it wears now.
|
|
const list = resolved ? [{ ...OCCURRENCE, id: resolved }] : [];
|
|
return [name, { accountId: "a1", state: "1", list, notFound: [] }, id];
|
|
}
|
|
if (name === "CalendarEvent/set") {
|
|
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
|
|
return [name, {
|
|
accountId: "a1", oldState: "1", newState: "2",
|
|
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
|
|
destroyed: (args.destroy ?? []) as string[],
|
|
notUpdated: {}, notDestroyed: {},
|
|
}, 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 calls;
|
|
}
|
|
|
|
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: { [OCCURRENCE.id]: OCCURRENCE },
|
|
ranges: {},
|
|
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("eventIdForScope", () => {
|
|
it("walks an occurrence up to its master for the series", () => {
|
|
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
|
|
});
|
|
it("sends the instance as it came for a single occurrence", () => {
|
|
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
|
|
});
|
|
it("resolves a master to itself under either scope", () => {
|
|
expect(eventIdForScope(MASTER, "series")).toBe("i");
|
|
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
|
|
});
|
|
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
|
|
// An expanded query gives a one-off an instance id over a different base.
|
|
// Stalwart resolves a synthetic id on a component that is neither recurrent
|
|
// nor an override back to the base event, so both scopes are safe here —
|
|
// but only `series` sends the id that is unambiguously the event.
|
|
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
|
|
expect(isOccurrence(ONE_OFF)).toBe(true);
|
|
});
|
|
it("does not call a master an occurrence", () => {
|
|
expect(isOccurrence(MASTER)).toBe(false);
|
|
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("destroyEvent", () => {
|
|
it("sends the master id for a series, never the synthetic one", async () => {
|
|
const calls = server();
|
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
|
|
expect(calls[0]!.destroy).toEqual(["i"]);
|
|
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
|
});
|
|
|
|
it("sends the id the occurrence answers to now, not the one it was handed", async () => {
|
|
// The live finding: writing one override renumbers the series, so an id
|
|
// cached a moment ago addresses a different date. `recurrenceId` is the
|
|
// stable handle, so the store looks the current id up by it.
|
|
const calls = server({ resolvesTo: "renumbered7" });
|
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
|
expect(calls[0]!.destroy).toEqual(["renumbered7"]);
|
|
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
|
});
|
|
|
|
it("refuses rather than guessing when the date is no longer in the series", async () => {
|
|
const calls = server({ resolvesTo: null });
|
|
await expect(useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence"))
|
|
.rejects.toThrow(/no longer part of this series/i);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("drops the occurrence from the cache without evicting the master", async () => {
|
|
server();
|
|
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
|
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
|
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
|
|
expect(useCalendar.getState().events.i).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("updateEvent", () => {
|
|
it("patches the master for a series", async () => {
|
|
const calls = server();
|
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
|
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
|
});
|
|
|
|
it("patches the id the occurrence answers to now", async () => {
|
|
const calls = server({ resolvesTo: "renumbered7" });
|
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
|
|
expect(Object.keys(calls[0]!.update!)).toEqual(["renumbered7"]);
|
|
});
|
|
});
|
|
|
|
describe("rsvp", () => {
|
|
it("answers for the series even when handed an occurrence", async () => {
|
|
// The patch itself survives either scope: `participationStatus` is one of
|
|
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
|
|
// instance would quietly mean "only that day" and nothing would say so.
|
|
const calls = server();
|
|
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
|
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
|
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
|
|
});
|
|
|
|
it("refuses when the signed-in identity is not a participant", async () => {
|
|
server();
|
|
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
|
|
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
|
});
|
|
});
|
|
|
|
|
|
describe("occurrencePatch", () => {
|
|
it("lets through what one date will actually take", () => {
|
|
const { patch, dropped } = occurrencePatch({ title: "Just today", color: "#f00" });
|
|
expect(patch).toEqual({ title: "Just today", color: "#f00" });
|
|
expect(dropped).toEqual([]);
|
|
});
|
|
|
|
it("throws on a property the server refuses outright", () => {
|
|
// Loud is correct here: moving one occurrence to another calendar is not
|
|
// something the user can be quietly given a different answer to.
|
|
expect(() => occurrencePatch({ calendarIds: { c2: true } })).toThrow(OccurrenceScopeError);
|
|
expect(() => occurrencePatch({ useDefaultAlerts: false })).toThrow(/whole series/i);
|
|
});
|
|
|
|
it("removes an inherited property and reports it, rather than letting it vanish", () => {
|
|
// The server would take this patch, drop `privacy`, and answer "updated".
|
|
// Anything that believes the response believes the change landed.
|
|
const { patch, dropped } = occurrencePatch({ title: "x", privacy: "private", recurrenceRule: null });
|
|
expect(patch).toEqual({ title: "x" });
|
|
expect(dropped).toEqual(["privacy", "recurrenceRule"]);
|
|
});
|
|
|
|
it("judges a pointer patch on its first token, as the server does", () => {
|
|
expect(occurrencePatch({ "participants/me/participationStatus": "accepted" }).patch)
|
|
.toEqual({ "participants/me/participationStatus": "accepted" });
|
|
expect(occurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).dropped)
|
|
.toEqual(["participants/me/calendarAddress"]);
|
|
});
|
|
});
|
|
|
|
describe("updateEvent, per occurrence", () => {
|
|
it("narrows the patch before sending it and reports what it kept back", async () => {
|
|
const calls = server();
|
|
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { title: "Just today", privacy: "private" }, false, "occurrence");
|
|
expect(calls[0]!.update).toEqual({ iaaaaas: { title: "Just today" } });
|
|
expect(dropped).toEqual(["privacy"]);
|
|
});
|
|
|
|
it("sends nothing at all when a patch is entirely inherited", async () => {
|
|
// A request that could only be a no-op is worse than no request: the
|
|
// response would say "updated" and mean nothing by it.
|
|
const calls = server();
|
|
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private" }, false, "occurrence");
|
|
expect(calls).toEqual([]);
|
|
expect(dropped).toEqual(["privacy"]);
|
|
});
|
|
|
|
it("leaves a series patch exactly as the caller wrote it", async () => {
|
|
const calls = server();
|
|
await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private", useDefaultAlerts: false }, false, "series");
|
|
expect(calls[0]!.update!.i).toEqual({ privacy: "private", useDefaultAlerts: false });
|
|
});
|
|
});
|
|
|
|
describe("isThisAndFutureRefusal", () => {
|
|
it("recognises the refusal worth offering the series for", () => {
|
|
expect(isThisAndFutureRefusal(new CalendarSetError({
|
|
type: "invalidProperties",
|
|
description: "Occurrences of a this-and-future change cannot be modified individually.",
|
|
}))).toBe(true);
|
|
});
|
|
it("does not claim an unrelated refusal", () => {
|
|
expect(isThisAndFutureRefusal(new CalendarSetError({ type: "forbidden", description: "Nope." }))).toBe(false);
|
|
expect(isThisAndFutureRefusal(new Error("Occurrences of a this-and-future change"))).toBe(false);
|
|
});
|
|
});
|