275 lines
13 KiB
TypeScript
275 lines
13 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
|
|
|
/**
|
|
* The mock expands recurrences so that per-occurrence editing can be developed
|
|
* against something. What it has to get right is not the expansion — that is
|
|
* the easy half — but the three things a live server does that a client will
|
|
* otherwise be written against wrongly:
|
|
*
|
|
* - every expanded id is synthetic, one-offs included;
|
|
* - an occurrence carries a `recurrenceId` and no rule;
|
|
* - a per-occurrence patch loses some properties in silence.
|
|
*/
|
|
|
|
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
|
|
|
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
|
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
|
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
|
|
|
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
|
|
|
describe("expandOccurrences", () => {
|
|
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
|
const out = expandOccurrences(series(), a, b);
|
|
assert.deepEqual(out.map((o) => o.start), [
|
|
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
|
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
|
]);
|
|
});
|
|
|
|
it("gives a one-off exactly one occurrence, at index 0", () => {
|
|
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
|
const out = expandOccurrences(oneOff(), a, b);
|
|
assert.equal(out.length, 1);
|
|
assert.equal(out[0]!.index, 0);
|
|
});
|
|
|
|
it("honors count", () => {
|
|
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
|
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
|
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
|
});
|
|
|
|
it("drops an excluded date from the expansion, keeping the series positions", () => {
|
|
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
|
const out = expandOccurrences(ev, a, b);
|
|
assert.deepEqual(out.map((o) => o.start), [
|
|
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
|
]);
|
|
// The position within the series is unchanged — Wednesday is still the
|
|
// third date the rule produces, whatever happened to Tuesday. It is the
|
|
// *id* built on top of that which moves, and only after a write.
|
|
assert.equal(out[1]!.index, 2);
|
|
});
|
|
|
|
it("carries an override onto the occurrence it keys", () => {
|
|
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
|
const out = expandOccurrences(ev, a, b);
|
|
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
|
});
|
|
});
|
|
|
|
describe("occurrenceView", () => {
|
|
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
|
const base = series();
|
|
const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
|
|
const view = occurrenceView(base, occ);
|
|
assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
|
|
assert.equal(view.baseEventId, "ev1");
|
|
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
|
assert.equal(view.recurrenceRule, undefined);
|
|
assert.equal(view.recurrenceOverrides, undefined);
|
|
});
|
|
|
|
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
|
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
|
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
|
const base = oneOff();
|
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
|
|
assert.equal(view.id, "ev2-r20260908T120000");
|
|
assert.equal(view.baseEventId, "ev2");
|
|
assert.notEqual(view.id, view.baseEventId);
|
|
assert.equal(view.recurrenceId, undefined);
|
|
});
|
|
|
|
it("lets an override win over the series", () => {
|
|
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
|
// The same recurrence id as before the override was written, because that
|
|
// is now the whole point: the write does not move any other occurrence.
|
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
|
|
assert.equal(view.start, "2026-09-08T09:00:00");
|
|
assert.equal(view.title, "Moved");
|
|
});
|
|
});
|
|
|
|
describe("eventGetView", () => {
|
|
/*
|
|
* What 0.16.22 changed in `CalendarEvent/get`, read from its source and the
|
|
* tests that came with it (`tests/src/jmap/calendar/event.rs` and
|
|
* `instance.rs`).
|
|
*/
|
|
it("reports no base for an event read by its stored id", () => {
|
|
// 0.16.21 answered with the event's own id here.
|
|
assert.deepEqual(eventGetView(oneOff(), false, ["id", "baseEventId"]), { id: "ev2", baseEventId: null });
|
|
assert.equal(eventGetView(series(), false, ["baseEventId"]).baseEventId, null);
|
|
});
|
|
|
|
it("still gives a one-off read through its synthetic id a base", () => {
|
|
// An expanded query hands a one-off a synthetic id, so this has not
|
|
// changed: `baseEventId` is still no evidence of a series.
|
|
const base = oneOff();
|
|
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!), true, ["baseEventId"]);
|
|
assert.equal(view.baseEventId, "ev2");
|
|
});
|
|
|
|
it("answers null for the rule and overrides named on an occurrence", () => {
|
|
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
|
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!), true,
|
|
["recurrenceId", "recurrenceRule", "recurrenceOverrides"]);
|
|
assert.deepEqual(view, { id: "ev1-r20260908T090000", recurrenceId: "2026-09-08T09:00:00", recurrenceRule: null, recurrenceOverrides: null });
|
|
});
|
|
|
|
it("leaves the rule on the series itself alone", () => {
|
|
assert.deepEqual(eventGetView(series(), false, ["recurrenceRule"]).recurrenceRule, WEEKDAYS);
|
|
});
|
|
|
|
it("reads useDefaultAlerts as false until it is set", () => {
|
|
// It used to read true until set.
|
|
assert.equal(eventGetView(series(), false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
|
assert.equal(eventGetView({ ...series(), useDefaultAlerts: true }, false, ["useDefaultAlerts"]).useDefaultAlerts, true);
|
|
assert.equal(eventGetView({ ...series(), useDefaultAlerts: false }, false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
|
});
|
|
|
|
it("returns only the id for an empty list", () => {
|
|
// 0.16.21 treated an empty list as asking for everything.
|
|
assert.deepEqual(eventGetView(series(), false, []), { id: "ev1" });
|
|
});
|
|
|
|
it("returns the object unchanged when no list is given", () => {
|
|
assert.deepEqual(eventGetView(series(), false, null), series());
|
|
});
|
|
});
|
|
|
|
describe("parseSyntheticId", () => {
|
|
it("round-trips", () => {
|
|
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
|
{ baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" });
|
|
});
|
|
it("does not claim a stored id", () => {
|
|
assert.equal(parseSyntheticId("ev1"), null);
|
|
});
|
|
it("does not claim an id that merely ends in digits", () => {
|
|
assert.equal(parseSyntheticId("ev1-r2026"), null);
|
|
});
|
|
});
|
|
|
|
describe("splitOccurrencePatch", () => {
|
|
it("applies what an occurrence takes", () => {
|
|
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
|
assert.equal(rejected, undefined);
|
|
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
|
});
|
|
|
|
it("refuses an event-level property by name", () => {
|
|
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
|
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
|
});
|
|
|
|
it("drops an inherited property in silence, which is the dangerous half", () => {
|
|
// No `rejected`, nothing applied, and a real server would still answer
|
|
// "updated". Anything that trusts the response believes this landed.
|
|
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
|
assert.equal(rejected, undefined);
|
|
assert.deepEqual(applied, {});
|
|
});
|
|
|
|
it("judges a pointer patch on its first token", () => {
|
|
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
|
{ "participants/me/participationStatus": "accepted" });
|
|
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
|
});
|
|
});
|
|
|
|
|
|
describe("synthetic ids survive a write", () => {
|
|
/*
|
|
* This used to assert the opposite, and the reversal is the point.
|
|
*
|
|
* Up to 0.16.20 a synthetic id encoded a position, so writing one override
|
|
* renumbered the series and a held id silently began naming a different
|
|
* date — confirmed live on 2026-08-31, and reproduced here on purpose so a
|
|
* client could not be written against a comfort the server did not offer.
|
|
*
|
|
* 0.16.21 identifies an occurrence by its recurrence id instead.
|
|
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was
|
|
* expanded, its third occurrence retitled through the synthetic id, and all
|
|
* five original ids re-read. Every one resolved, and every one still named
|
|
* its own date. So the hazard is gone, and the mock stops teaching it.
|
|
*/
|
|
it("keeps a cached id on the same date after an override is written", () => {
|
|
const before = series();
|
|
const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
|
|
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
|
|
|
|
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
|
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
|
|
|
|
assert.equal(dateAfter, dateBefore);
|
|
});
|
|
|
|
it("resolves every id of a series after one of them is overridden", () => {
|
|
const before = series();
|
|
const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
|
|
.map((o) => syntheticId("ev1", o.recurrenceId));
|
|
const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
|
|
for (const id of held) {
|
|
const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId);
|
|
assert.ok(occ, `${id} should still resolve`);
|
|
assert.equal(syntheticId("ev1", occ.recurrenceId), id);
|
|
}
|
|
});
|
|
|
|
it("still refuses an id whose date the rule no longer generates", () => {
|
|
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
|
|
assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null);
|
|
});
|
|
});
|
|
|
|
|
|
describe("an override that moves an occurrence", () => {
|
|
/*
|
|
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
|
|
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
|
|
* still at 09:00 — the slot the rule made, which the move does not touch.
|
|
*
|
|
* The mock used to clobber the override's `start` with the slot time, so a
|
|
* moved occurrence did not move. That made per-occurrence *time* editing —
|
|
* one of the main things the feature is for — look broken against the mock
|
|
* and fine against the server.
|
|
*/
|
|
const moved = () => ({
|
|
...series(),
|
|
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
|
|
});
|
|
|
|
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
|
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
|
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
|
assert.equal(occ.start, "2026-09-08T14:00:00");
|
|
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
|
|
});
|
|
|
|
it("shows the moved time on the occurrence a get returns", () => {
|
|
const base = moved();
|
|
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
|
|
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
|
const view = occurrenceView(base, occ);
|
|
assert.equal(view.start, "2026-09-08T14:00:00");
|
|
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
|
});
|
|
|
|
it("keeps the occurrence findable by recurrenceId after the move", () => {
|
|
// This is the property the store depends on: `recurrenceId` survives both
|
|
// a renumbering and a move, so it is the handle a mutation resolves from.
|
|
const base = moved();
|
|
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
|
|
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
|
|
});
|
|
});
|