Files
webmail/lib/__tests__/jmap-first-touch-ordering.test.ts
T
Linus Rath 19c2dfb2b4 fix: keep the calendar working on Stalwart 1.0 with an unknown time zone
Stalwart 1.0 fails every CalendarEvent get and query whose `timeZone`
argument names a zone missing from its time zone database, with
invalidArguments "Invalid timeZone". 0.16 reads such a zone as UTC, so
the calendar kept working there; on 1.0 it would stay empty.

- check the user's zone with a get of no events that rides along with
  the session's first calendar list
- once the server refuses the zone, ask in UTC, move the grid's
  wall-clock range bounds to UTC as well, and log one console warning
- repeat a read that was refused before the check answered, once, in
  UTC
2026-09-25 19:35:08 +02:00

143 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
/**
* #907: Stalwart lazily creates an account's default calendar / address book on
* the first request that touches the collection. When Bulwark's login burst
* fires Calendar/get and CalendarEvent/query concurrently against a clustered
* server, each node can create its own default - duplicating the "Personal"
* calendar. The client must therefore not put a second calendar (or contacts)
* request on the wire until the first one for that account has settled, while
* leaving unrelated requests (Mailbox/get) unaffected.
*/
function makeSession() {
return {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:contacts': {},
},
accounts: {
'acct-1': {
name: 'test',
isPersonal: true,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:contacts': {},
},
},
},
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'acct-1',
'urn:ietf:params:jmap:calendars': 'acct-1',
'urn:ietf:params:jmap:contacts': 'acct-1',
},
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
interface Pending {
methods: string[];
resolve: (body: unknown) => void;
}
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
describe('JMAPClient first-touch ordering (#907)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let onWire: Pending[];
beforeEach(async () => {
onWire = [];
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function connectedClient(): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
// Every API call from here on is parked until the test releases it, in the
// order it reached the (mock) network.
fetchSpy.mockImplementation((_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}')) as { methodCalls?: [string][] };
return new Promise<Response>((resolve) => {
onWire.push({
methods: (body.methodCalls ?? []).map((c) => c[0]),
resolve: (responseBody) => resolve(jsonResponse(responseBody)),
});
});
});
return client;
}
const emptyResponse = (method: string) => ({ methodResponses: [[method, { list: [], ids: [] }, '0']] });
it('holds CalendarEvent/query until the first Calendar/get for the account has settled', async () => {
const client = await connectedClient();
const calendars = client.getAllCalendars();
const events = client.queryCalendarEvents({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' });
await flush();
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get']);
onWire[0].resolve(emptyResponse('Calendar/get'));
await expect(calendars).resolves.toEqual([]);
await flush();
// The synthetic-id probe (lib/recurrence-instances.ts) rides along in
// the Calendar/get request, and so does the time zone check unless the
// zone is UTC: the query needs no request of its own first.
expect(onWire[0].methods.slice(0, 2)).toEqual(['Calendar/get', 'CalendarEvent/set']);
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get', 'CalendarEvent/query']);
onWire[1].resolve(emptyResponse('CalendarEvent/query'));
await expect(events).resolves.toEqual([]);
});
it('does not delay unrelated requests, nor contacts behind calendars', async () => {
const client = await connectedClient();
const calendars = client.getAllCalendars();
const mailboxes = client.getMailboxes();
const books = client.getAddressBooks();
await flush();
expect(onWire.map((p) => p.methods[0]).sort()).toEqual(['AddressBook/get', 'Calendar/get', 'Mailbox/get']);
for (const p of onWire) p.resolve(emptyResponse(p.methods[0]));
await Promise.all([calendars, mailboxes, books]);
});
it('lets calendar requests run concurrently after the first touch', async () => {
const client = await connectedClient();
const warm = client.getAllCalendars();
await flush();
onWire[0].resolve(emptyResponse('Calendar/get'));
await warm;
onWire = [];
const a = client.queryCalendarEvents({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' });
const b = client.getAllCalendars();
await flush();
expect(onWire.map((p) => p.methods[0]).sort()).toEqual(['Calendar/get', 'CalendarEvent/query']);
for (const p of onWire) p.resolve(emptyResponse(p.methods[0]));
await Promise.all([a, b]);
});
});