Files
webmail/lib/__tests__/jmap-calendar-batch-failures.test.ts
T
Linus Rath 8db65135c6 fix: load calendar events and tasks on Stalwart 1.0
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every calendar
read did. The grid, tasks, reminders, search, contact activity and the
agenda plugin came back empty with no error, and creating an event or
task threw after the server had created it, so a retry made a
duplicate.

- read overrides with a second get in the same request, only where
  they are needed
- treat an error answer to a get or query as a failure; the calendar
  keeps the last events and says it could not load them, with a retry
- never throw after the server created an event or task
- let the server expand series for /api/calendar-agenda
2026-09-25 17:30:42 +02:00

171 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// #434: batchDeleteCalendarEvents ignored a method-level error and dropped the
// notDestroyed details, so "Clear events" reported "0 events cleared" with no
// error. The same for batchCreateCalendarEvents' notCreated on import.
function makeSession() {
return {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:calendars': {},
},
accounts: {
'acct-1': {
name: 'test',
isPersonal: true,
accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} },
},
},
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'acct-1',
'urn:ietf:params:jmap:calendars': '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' },
});
}
describe('calendar batch failures surface (#434)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function connectedClient(): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
it('batchDeleteCalendarEvents rejects with the server reason when nothing was destroyed', async () => {
const client = await connectedClient();
fetchSpy.mockResolvedValueOnce(
jsonResponse({
methodResponses: [
['CalendarEvent/set', {
destroyed: null,
notDestroyed: { ev1: { type: 'forbidden', description: 'You do not own this calendar' } },
}, '0'],
],
}),
);
await expect(client.batchDeleteCalendarEvents(['ev1'])).rejects.toThrow(/You do not own this calendar/);
});
it('batchDeleteCalendarEvents rejects on a method-level error', async () => {
const client = await connectedClient();
fetchSpy.mockResolvedValueOnce(
jsonResponse({
methodResponses: [['error', { type: 'accountNotFound', description: 'Account not found' }, '0']],
}),
);
await expect(client.batchDeleteCalendarEvents(['ev1'])).rejects.toThrow(/Account not found/);
});
it('batchDeleteCalendarEvents returns the notDestroyed map for partial failures', async () => {
const client = await connectedClient();
fetchSpy.mockResolvedValueOnce(
jsonResponse({
methodResponses: [
['CalendarEvent/set', {
destroyed: ['ev1'],
notDestroyed: { ev2: { type: 'notFound', description: 'Gone' } },
}, '0'],
],
}),
);
const result = await client.batchDeleteCalendarEvents(['ev1', 'ev2']);
expect(result.destroyed).toEqual(['ev1']);
expect(result.notDestroyed).toEqual({ ev2: { type: 'notFound', description: 'Gone' } });
});
it('batchCreateCalendarEvents rejects on a method-level error and reports notCreated otherwise', async () => {
const client = await connectedClient();
fetchSpy.mockResolvedValueOnce(
jsonResponse({
methodResponses: [['error', { type: 'invalidArguments', description: 'Unknown calendar' }, '0']],
}),
);
await expect(client.batchCreateCalendarEvents([{ title: 'A' }])).rejects.toThrow(/Unknown calendar/);
fetchSpy.mockResolvedValueOnce(
jsonResponse({
methodResponses: [
['CalendarEvent/set', {
created: null,
notCreated: { 'new-0': { type: 'invalidProperties', description: 'Missing start' } },
}, '0'],
],
}),
);
const result = await client.batchCreateCalendarEvents([{ title: 'A' }]);
expect(result.created).toEqual([]);
expect(result.failed).toEqual(['new-0']);
expect(result.notCreated['new-0']).toMatchObject({ description: 'Missing start' });
});
it('batchCreateCalendarEvents reads the events back without their overrides', async () => {
const client = await connectedClient();
fetchSpy
.mockResolvedValueOnce(jsonResponse({
methodResponses: [['CalendarEvent/set', { created: { 'new-0': { id: 'ev1' } } }, '0']],
}))
.mockResolvedValueOnce(jsonResponse({
methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev1', title: 'A', start: '2026-09-22T10:00:00' }] }, '0']],
}));
const result = await client.batchCreateCalendarEvents([{ title: 'A', start: '2026-09-22T10:00:00' }]);
expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }]);
const readBack = JSON.parse(String((fetchSpy.mock.calls[1][1] as RequestInit).body));
expect(readBack.methodCalls).toHaveLength(1);
expect(readBack.methodCalls[0][1].properties).toContain('utcStart');
expect(readBack.methodCalls[0][1].properties).not.toContain('recurrenceOverrides');
});
it('batchCreateCalendarEvents still counts the created events when Stalwart 1.0 refuses the read-back', async () => {
const client = await connectedClient();
fetchSpy
.mockResolvedValueOnce(jsonResponse({
methodResponses: [['CalendarEvent/set', {
created: { 'new-0': { id: 'ev1' }, 'new-1': { id: 'ev2' } },
}, '0']],
}))
.mockResolvedValueOnce(jsonResponse({
methodResponses: [['error', {
type: 'invalidArguments',
description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd',
}, '0']],
}));
const result = await client.batchCreateCalendarEvents([
{ title: 'A', start: '2026-09-22T10:00:00' },
{ title: 'B', start: '2026-09-23T10:00:00' },
]);
expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }, { id: 'ev2', title: 'B' }]);
expect(result.failed).toEqual([]);
});
});