Follow Stalwart 0.16.22 in the mock
0.16.22 changed four things a client sees from CalendarEvent/get and ContactCard/get. Read from its source and the tests that came with it: - baseEventId is the master's id on a synthetic id and null otherwise; an event read by its stored id used to report its own id. A one-off from an expanded query still has a synthetic id, so it still carries a base. - recurrenceRule and recurrenceOverrides named on a synthetic id come back null rather than absent. - useDefaultAlerts is the reader's own and reads false until set. - an empty properties list returns id alone, for both methods. pick already did that, so only a comment changes for contacts. With properties omitted the stored object comes back as before. The README said a one-off now carries a null base, which only holds for one read by its stored id; it now says that, and that the mock follows.
This commit is contained in:
@@ -91,12 +91,12 @@ on the first call.
|
|||||||
**Validated against 0.16.22**, released 13 September 2026: the live instance
|
**Validated against 0.16.22**, released 13 September 2026: the live instance
|
||||||
runs it and the app has been tested against it. Four of its JMAP changes are
|
runs it and the app has been tested against it. Four of its JMAP changes are
|
||||||
visible to a client, all in calendars and contacts:
|
visible to a client, all in calendars and contacts:
|
||||||
`CalendarEvent/get` returns `baseEventId` only on a synthetic occurrence, so a
|
`CalendarEvent/get` returns `baseEventId` only for a synthetic id, so an event
|
||||||
one-off event now carries `null` there; it returns `null` for `recurrenceRule`
|
read by its stored id now carries `null` there rather than its own id; it
|
||||||
and `recurrenceOverrides` asked for on an occurrence; `useDefaultAlerts` is
|
returns `null` for `recurrenceRule` and `recurrenceOverrides` asked for on a
|
||||||
stored per user and reads `false` when never set; and `CalendarEvent/get` and
|
synthetic id; `useDefaultAlerts` is stored per user and reads `false` when never
|
||||||
`ContactCard/get` return only `id` for an empty `properties` list, rather than
|
set; and `CalendarEvent/get` and `ContactCard/get` return only `id` for an empty
|
||||||
everything. The mock does not follow these yet.
|
`properties` list, rather than everything. The mock reproduces all four.
|
||||||
|
|
||||||
Before it, **0.16.21**, released 6 September 2026: the app was run against a
|
Before it, **0.16.21**, released 6 September 2026: the app was run against a
|
||||||
real instance of it and the mail, calendar and contacts paths were exercised by
|
real instance of it and the mail, calendar and contacts paths were exercised by
|
||||||
|
|||||||
@@ -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, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
import { eventGetView, 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";
|
||||||
import { createDirectory, mockRole } from "./directory.js";
|
import { createDirectory, mockRole } from "./directory.js";
|
||||||
@@ -1263,15 +1263,17 @@ const handlers: Record<string, Handler> = {
|
|||||||
"CalendarEvent/get": (a) => {
|
"CalendarEvent/get": (a) => {
|
||||||
const list = eventsFor(a.accountId);
|
const list = eventsFor(a.accountId);
|
||||||
const ids = a.ids as string[] | null | undefined;
|
const ids = a.ids as string[] | null | undefined;
|
||||||
if (!ids) return genericGet(list)(a);
|
const properties = a.properties as string[] | null | undefined;
|
||||||
|
// With no ids every event comes back under its stored id, none synthetic.
|
||||||
|
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
|
||||||
const found: Obj[] = [];
|
const found: Obj[] = [];
|
||||||
const notFound: string[] = [];
|
const notFound: string[] = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const resolved = resolveEvent(list, id);
|
const resolved = resolveEvent(list, id);
|
||||||
if (!resolved) { notFound.push(id); continue; }
|
if (!resolved) { notFound.push(id); continue; }
|
||||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||||
}
|
}
|
||||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
|
||||||
},
|
},
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||||
@@ -1311,6 +1313,8 @@ const handlers: Record<string, Handler> = {
|
|||||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||||
},
|
},
|
||||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||||
|
// An empty `properties` list returns `id` alone, which `pick` already does.
|
||||||
|
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
|
||||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||||
"ContactCard/set": genericSet(cards, "cc"),
|
"ContactCard/set": genericSet(cards, "cc"),
|
||||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
|
|||||||
@@ -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, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
import { eventGetView, 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
|
||||||
@@ -98,6 +98,54 @@ describe("occurrenceView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("parseSyntheticId", () => {
|
||||||
it("round-trips", () => {
|
it("round-trips", () => {
|
||||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
|
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.22.
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -188,6 +188,43 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Series properties a synthetic id answers `null` for, when they are named. */
|
||||||
|
const NULL_ON_OCCURRENCE = new Set(["recurrenceRule", "recurrenceOverrides"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object a `CalendarEvent/get` with a `properties` list returns, as 0.16.22
|
||||||
|
* builds it. Omitted or null `properties` returns the stored object unchanged.
|
||||||
|
*
|
||||||
|
* Three of the named properties are no longer read off the object:
|
||||||
|
*
|
||||||
|
* - `baseEventId` is the master's id on a synthetic id and `null` on anything
|
||||||
|
* else. Through 0.16.21 an event read by its stored id reported that id as
|
||||||
|
* its own base. An expanded query still hands a one-off a synthetic id, so
|
||||||
|
* one read that way still carries a base, and `baseEventId` is still no
|
||||||
|
* evidence of a series;
|
||||||
|
* - `recurrenceRule` and `recurrenceOverrides` come back as `null` on a
|
||||||
|
* synthetic id rather than being left out;
|
||||||
|
* - `useDefaultAlerts` is the reader's own preference, and `false` when they
|
||||||
|
* never set one. It used to read `true` until set. The mock has one reader,
|
||||||
|
* so a value stored on the event stands in for that reader's.
|
||||||
|
*
|
||||||
|
* An empty list returns `id` alone, where 0.16.21 treated it as asking for
|
||||||
|
* everything. `ContactCard/get` changed the same way.
|
||||||
|
*
|
||||||
|
* Read from the 0.16.22 source (`calendar_event/get.rs`) and its tests.
|
||||||
|
*/
|
||||||
|
export function eventGetView(event: Obj, synthetic: boolean, properties: string[] | null | undefined): Obj {
|
||||||
|
if (!properties) return event;
|
||||||
|
const out: Obj = { id: event.id };
|
||||||
|
for (const p of properties) {
|
||||||
|
if (p === "baseEventId") out[p] = synthetic ? event.baseEventId : null;
|
||||||
|
else if (p === "useDefaultAlerts") out[p] = event.useDefaultAlerts === true;
|
||||||
|
else if (synthetic && NULL_ON_OCCURRENCE.has(p)) out[p] = null;
|
||||||
|
else if (p in event) out[p] = event[p];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- what a single occurrence will not take ---------- */
|
/* ---------- what a single occurrence will not take ---------- */
|
||||||
|
|
||||||
/** Refused outright, with `invalidProperties`. */
|
/** Refused outright, with `invalidProperties`. */
|
||||||
|
|||||||
Reference in New Issue
Block a user