Merge pull request #293 from Coffey-Labs/mock-0-16-21
Follow Stalwart 0.16.21 in the mock
This commit is contained in:
+88
-18
@@ -6,7 +6,7 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||||
|
|
||||||
@@ -358,6 +358,24 @@ function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
|
|||||||
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
||||||
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||||
|
|
||||||
|
/** The floor Stalwart puts under a requested EventSource ping interval. */
|
||||||
|
const PING_FLOOR_SECONDS = 30;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An account that may not send calendar invitations.
|
||||||
|
*
|
||||||
|
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
|
||||||
|
* the account lacks the `calendarSchedulingSend` permission, rather than
|
||||||
|
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
|
||||||
|
* (2026-09-06)** against an account holding a role with that permission
|
||||||
|
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
|
||||||
|
* `forbidden` and the text below, while the identical request with the flag
|
||||||
|
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
|
||||||
|
* that account.
|
||||||
|
*/
|
||||||
|
const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
|
||||||
|
const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
|
||||||
|
|
||||||
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||||
/** One per contact, by index; a gap means that card has no birthday. */
|
/** One per contact, by index; a gap means that card has no birthday. */
|
||||||
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
||||||
@@ -542,12 +560,19 @@ function enforceLimits(name: string, args: Obj): void {
|
|||||||
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
||||||
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
* name: a `/get` with no `properties` comes back without the field at all.
|
||||||
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
||||||
* shared. The mock handing it over unasked meant a client that never asked
|
* The mock handing it over unasked meant a client that never asked still saw
|
||||||
* still saw every share, and the one place that did not -- the real server --
|
* every share, and the one place that did not -- the real server -- showed
|
||||||
* showed nothing shared at all.
|
* nothing shared at all.
|
||||||
|
*
|
||||||
|
* Calendars and address books used to behave the same way and no longer do.
|
||||||
|
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
||||||
|
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
||||||
|
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
||||||
|
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
||||||
|
* stays applied to mailboxes alone.
|
||||||
*/
|
*/
|
||||||
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||||
if (a.properties) return res;
|
if (a.properties) return res;
|
||||||
@@ -564,9 +589,9 @@ function genericGet(list: Obj[]) {
|
|||||||
/**
|
/**
|
||||||
* An id, as either a stored event or one occurrence of one.
|
* An id, as either a stored event or one occurrence of one.
|
||||||
*
|
*
|
||||||
* A synthetic id whose base is gone, or whose index falls outside the series
|
* A synthetic id whose base is gone, or whose date the rule no longer
|
||||||
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
||||||
* server answers for an occurrence that is not there any more.
|
* the way the server answers for an occurrence that is not there any more.
|
||||||
*/
|
*/
|
||||||
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||||
const direct = list.find((x) => x.id === id);
|
const direct = list.find((x) => x.id === id);
|
||||||
@@ -575,7 +600,7 @@ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence }
|
|||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
const base = list.find((x) => x.id === parsed.baseId);
|
const base = list.find((x) => x.id === parsed.baseId);
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
const occ = occurrenceAt(base, parsed.slot);
|
const occ = occurrenceAt(base, parsed.recurrenceId);
|
||||||
return occ ? { base, occ } : null;
|
return occ ? { base, occ } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +722,27 @@ function calendarEventSet(a: Obj) {
|
|||||||
const notUpdated: Obj = {};
|
const notUpdated: Obj = {};
|
||||||
const notDestroyed: Obj = {};
|
const notDestroyed: Obj = {};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An account that may not send invitations refuses the whole request the
|
||||||
|
* moment it asks for them, and refuses it per object rather than as a method
|
||||||
|
* error. Confirmed live on 0.16.21 for all three of create, update and
|
||||||
|
* destroy; the same requests with the flag absent or false went through.
|
||||||
|
* The flag alone decides it — the server does not first check whether the
|
||||||
|
* event has anyone to notify.
|
||||||
|
*/
|
||||||
|
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
||||||
|
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
||||||
|
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
||||||
|
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
||||||
|
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
||||||
|
return setResp({
|
||||||
|
created, updated, destroyed,
|
||||||
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||||
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||||
|
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
@@ -1154,7 +1200,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"SieveScript/get": genericGet(sieveScripts),
|
"SieveScript/get": genericGet(sieveScripts),
|
||||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
||||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
/*
|
/*
|
||||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||||
@@ -1173,7 +1219,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
|
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
|
||||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||||
},
|
},
|
||||||
"CalendarEvent/get": (a) => {
|
"CalendarEvent/get": (a) => {
|
||||||
@@ -1211,7 +1257,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
}
|
}
|
||||||
return { accountId: ACCOUNT, list };
|
return { accountId: ACCOUNT, list };
|
||||||
},
|
},
|
||||||
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
||||||
"AddressBook/set": (a) => {
|
"AddressBook/set": (a) => {
|
||||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||||
included -- "You are not allowed to modify this address book", confirmed
|
included -- "You are not allowed to modify this address book", confirmed
|
||||||
@@ -1393,13 +1439,37 @@ export const server = createServer(async (req, res) => {
|
|||||||
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
||||||
return res.end(b.data);
|
return res.end(b.data);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* The `ping` query parameter, and what comes back for it.
|
||||||
|
*
|
||||||
|
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
|
||||||
|
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
|
||||||
|
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
|
||||||
|
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
|
||||||
|
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
|
||||||
|
* altogether; a value that is not a number at all — `abc`, or empty — is a
|
||||||
|
* 400 before the stream opens.
|
||||||
|
*
|
||||||
|
* The first ping arrives one whole interval in, not on connect, so nothing
|
||||||
|
* is written here: `flushHeaders` opens the stream on its own. A mock that
|
||||||
|
* pinged immediately would let a client treat the first ping as an
|
||||||
|
* connection-established signal and hang forever against the real thing.
|
||||||
|
*/
|
||||||
if (url.pathname.startsWith("/jmap/eventsource")) {
|
if (url.pathname.startsWith("/jmap/eventsource")) {
|
||||||
|
const raw = url.searchParams.get("ping");
|
||||||
|
const asked = Number(raw);
|
||||||
|
if (raw === null || raw === "" || !Number.isInteger(asked) || asked < 0) {
|
||||||
|
res.writeHead(400, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:notRequest", status: 400 }));
|
||||||
|
}
|
||||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||||
res.write(`event: ping\ndata: {}\n\n`);
|
res.flushHeaders();
|
||||||
sseClients.add(res);
|
sseClients.add(res);
|
||||||
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000);
|
const interval = asked === 0 ? 0 : Math.max(asked, PING_FLOOR_SECONDS);
|
||||||
req.on("close", () => { clearInterval(t); sseClients.delete(res); });
|
const t = interval
|
||||||
// Simulate a new message every 90s
|
? setInterval(() => res.write(`event: ping\ndata: {"interval": ${interval}}\n\n`), interval * 1000)
|
||||||
|
: null;
|
||||||
|
req.on("close", () => { if (t) clearInterval(t); sseClients.delete(res); });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(404, { "content-type": "application/json" });
|
res.writeHead(404, { "content-type": "application/json" });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||||
@@ -68,9 +68,9 @@ describe("expandOccurrences", () => {
|
|||||||
describe("occurrenceView", () => {
|
describe("occurrenceView", () => {
|
||||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||||
const base = series();
|
const base = series();
|
||||||
const occ = occurrenceAt(base, 1)!;
|
const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
|
||||||
const view = occurrenceView(base, occ);
|
const view = occurrenceView(base, occ);
|
||||||
assert.equal(view.id, syntheticId("ev1", 1));
|
assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
|
||||||
assert.equal(view.baseEventId, "ev1");
|
assert.equal(view.baseEventId, "ev1");
|
||||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||||
assert.equal(view.recurrenceRule, undefined);
|
assert.equal(view.recurrenceRule, undefined);
|
||||||
@@ -81,8 +81,8 @@ describe("occurrenceView", () => {
|
|||||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
// 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.
|
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||||
const base = oneOff();
|
const base = oneOff();
|
||||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
|
||||||
assert.equal(view.id, "ev2-o0");
|
assert.equal(view.id, "ev2-r20260908T120000");
|
||||||
assert.equal(view.baseEventId, "ev2");
|
assert.equal(view.baseEventId, "ev2");
|
||||||
assert.notEqual(view.id, view.baseEventId);
|
assert.notEqual(view.id, view.baseEventId);
|
||||||
assert.equal(view.recurrenceId, undefined);
|
assert.equal(view.recurrenceId, undefined);
|
||||||
@@ -90,9 +90,9 @@ describe("occurrenceView", () => {
|
|||||||
|
|
||||||
it("lets an override win over the series", () => {
|
it("lets an override win over the series", () => {
|
||||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
// The same recurrence id as before the override was written, because that
|
||||||
// for the id this occurrence had *before* the write is the bug below.
|
// is now the whole point: the write does not move any other occurrence.
|
||||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
|
||||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||||
assert.equal(view.title, "Moved");
|
assert.equal(view.title, "Moved");
|
||||||
});
|
});
|
||||||
@@ -100,11 +100,15 @@ describe("occurrenceView", () => {
|
|||||||
|
|
||||||
describe("parseSyntheticId", () => {
|
describe("parseSyntheticId", () => {
|
||||||
it("round-trips", () => {
|
it("round-trips", () => {
|
||||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
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", () => {
|
it("does not claim a stored id", () => {
|
||||||
assert.equal(parseSyntheticId("ev1"), null);
|
assert.equal(parseSyntheticId("ev1"), null);
|
||||||
});
|
});
|
||||||
|
it("does not claim an id that merely ends in digits", () => {
|
||||||
|
assert.equal(parseSyntheticId("ev1-r2026"), null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("splitOccurrencePatch", () => {
|
describe("splitOccurrencePatch", () => {
|
||||||
@@ -135,35 +139,47 @@ describe("splitOccurrencePatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("synthetic ids are only true until the next write", () => {
|
describe("synthetic ids survive a write", () => {
|
||||||
/*
|
/*
|
||||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
* This used to assert the opposite, and the reversal is the point.
|
||||||
* entry renumbered a five-week series so that the *same* ids addressed
|
*
|
||||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
* Up to 0.16.20 a synthetic id encoded a position, so writing one override
|
||||||
* that rather than the exact permutation, because the property that bites is
|
* renumbered the series and a held id silently began naming a different
|
||||||
* not which date an id moves to but that it moves at all, silently.
|
* 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("makes a cached id address a different date after an override is written", () => {
|
it("keeps a cached id on the same date after an override is written", () => {
|
||||||
const before = series();
|
const before = series();
|
||||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
|
||||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||||
|
|
||||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||||
|
|
||||||
assert.notEqual(dateAfter, dateBefore);
|
assert.equal(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", () => {
|
it("resolves every id of a series after one of them is overridden", () => {
|
||||||
const before = series();
|
const before = series();
|
||||||
const occ = occurrenceAt(before, 3)!;
|
const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
|
||||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
.map((o) => syntheticId("ev1", o.recurrenceId));
|
||||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
|
||||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
for (const id of held) {
|
||||||
assert.equal(same!.start, occ.start);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
|
||||||
*
|
*
|
||||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||||
* things that only a live server showed were therefore impossible to develop
|
* things that only a live server showed were therefore impossible to develop
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
* - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
|
||||||
* `recurrenceOverrides` entry rather than touching the series.
|
* rather than touching the series.
|
||||||
*
|
*
|
||||||
* A mock that agrees with the client rather than with the server is how #26 and
|
* A mock that agrees with the client rather than with the server is how #26 and
|
||||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||||
@@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750;
|
|||||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id an occurrence is addressed by, which is only true until the next write.
|
* The id an occurrence is addressed by: its `recurrenceId`, not its position.
|
||||||
*
|
*
|
||||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||||
* them, and nothing in ihasmail may read either.
|
* them, and nothing in ihasmail may read either.
|
||||||
*
|
*
|
||||||
* They are also deliberately **unstable**, because the real ones are.
|
* **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
|
||||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
* encoded a *position* in the expanded series, so writing one override
|
||||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
* renumbered the rest and a held id silently began addressing a different
|
||||||
* component that renumbers it. A five-week series held `e i m q u` over
|
* date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
|
||||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
* an occurrence is now identified by its recurrence id.
|
||||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
|
||||||
* different dates.
|
|
||||||
*
|
*
|
||||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
|
||||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
* expanded, the third occurrence retitled through its synthetic id, and all
|
||||||
* would hand back a loud `notFound` and let a client that caches ids look
|
* five original ids re-read afterwards. Every one still resolved, and every
|
||||||
* careful. So the numbering is shifted by the number of overrides — an
|
* one still named its own date; nothing was renumbered and nothing was
|
||||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
* `notFound`. Only the *order* of the ids from an expanded query changed —
|
||||||
* matters: hold an id across a write and it silently addresses another date.
|
* the overridden occurrence moved to the end of the list — which is why a
|
||||||
|
* client sorts by `start` rather than trusting query order.
|
||||||
|
*
|
||||||
|
* The real ids look nothing like these (`h1fo9uaaaaab` for the first of that
|
||||||
|
* series); what has to match is that holding one across a write stays correct.
|
||||||
*/
|
*/
|
||||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
|
||||||
|
|
||||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
export const syntheticId = (baseId: string, recurrenceId: string): string =>
|
||||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
`${baseId}-r${compact(recurrenceId)}`;
|
||||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far the id numbering has been rotated away from the series order. */
|
export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
|
||||||
function rotation(base: Obj): number {
|
const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
|
||||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
if (!m) return null;
|
||||||
}
|
const c = m[2]!;
|
||||||
|
const recurrenceId =
|
||||||
/** The id slot this occurrence currently answers to. */
|
`${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
|
||||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
`T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
|
||||||
return occ.index + rotation(base);
|
return { baseId: m[1]!, recurrenceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||||
@@ -104,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
|||||||
const emit = (index: number, at: Date): boolean => {
|
const emit = (index: number, at: Date): boolean => {
|
||||||
const recurrenceId = localDateTime(at);
|
const recurrenceId = localDateTime(at);
|
||||||
const override = overrides[recurrenceId];
|
const override = overrides[recurrenceId];
|
||||||
// An excluded date is simply gone from the expansion. Its slot is not
|
// An excluded date is simply gone from the expansion. Nothing is
|
||||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
// reserved in its place, and no other occurrence's id moves because of it.
|
||||||
if (override?.excluded === true) return true;
|
if (override?.excluded === true) return true;
|
||||||
/*
|
/*
|
||||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||||
@@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
|||||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||||
* `2027-06-14T09:00:00`.
|
* `2027-06-14T09:00:00`.
|
||||||
*
|
*
|
||||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
* Which is exactly why `recurrenceId` is what a client holds on to, and
|
||||||
* the one name for this instance that neither a renumbering nor a move
|
* since 0.16.21 what the id is built from: the one name for this instance
|
||||||
* changes.
|
* that a move does not change.
|
||||||
*/
|
*/
|
||||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||||
const shown = parseLocal(start);
|
const shown = parseLocal(start);
|
||||||
@@ -178,7 +178,7 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
|||||||
const view: Obj = { ...base };
|
const view: Obj = { ...base };
|
||||||
for (const k of SERIES_ONLY) delete view[k];
|
for (const k of SERIES_ONLY) delete view[k];
|
||||||
Object.assign(view, occ.override ?? {});
|
Object.assign(view, occ.override ?? {});
|
||||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
view.id = syntheticId(base.id as string, occ.recurrenceId);
|
||||||
view.baseEventId = base.id;
|
view.baseEventId = base.id;
|
||||||
view.start = occ.start;
|
view.start = occ.start;
|
||||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||||
@@ -229,10 +229,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
|
|||||||
return { applied };
|
return { applied };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
/**
|
||||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
* The occurrence a recurrence id addresses, which no later write moves.
|
||||||
const index = slot - rotation(base);
|
*
|
||||||
if (index < 0) return null;
|
* An id whose date the rule no longer generates — excluded, or past a `count`
|
||||||
|
* — resolves to nothing, and the caller turns that into `notFound`.
|
||||||
|
*/
|
||||||
|
export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null {
|
||||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||||
return all.find((o) => o.index === index) ?? null;
|
return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user