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("recognizes 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);
|
|
});
|
|
});
|