Files
ihasmail-inbuxa/server/src/mock/recurrence.test.ts
T
jcoffey-dev 06943fd473 Mock: let an override move an occurrence, as the server does
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 stays put; only the
clock time moves.

The mock set `start` from the slot after merging the override, so it
clobbered any `start` the override carried and a moved occurrence did not
move. Per-occurrence *time* editing - one of the main things the feature
is for - therefore looked broken against the mock and correct against the
server, which is the wrong way round for a mock to be wrong.

It also confirms the choice of handle: `recurrenceId` is the one name for
an instance that survives both a renumbering and a move, which is why the
store re-resolves from it rather than from `start` or a cached id.
2026-08-30 21:49:53 -07:00

211 lines
9.9 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, 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("honours 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, 1)!;
const view = occurrenceView(base, occ);
assert.equal(view.id, syntheticId("ev1", 1));
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, 0)!);
assert.equal(view.id, "ev2-o0");
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" } } };
// Slot 2, not 1: one override has already shifted the numbering. Reaching
// for the id this occurrence had *before* the write is the bug below.
const view = occurrenceView(base, occurrenceAt(base, 2)!);
assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved");
});
});
describe("parseSyntheticId", () => {
it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
});
it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), 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 are only true until the next write", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
* entry renumbered a five-week series so that the *same* ids addressed
* different dates. Nothing was rejected. The mock reproduces the shape of
* that rather than the exact permutation, because the property that bites is
* not which date an id moves to but that it moves at all, silently.
*/
it("makes a cached id address a different date after an override is written", () => {
const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
assert.notEqual(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
});
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
const before = series();
const occ = occurrenceAt(before, 3)!;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
.find((o) => o.recurrenceId === occ.recurrenceId);
assert.equal(same!.start, occ.start);
});
});
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);
});
});