mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Merge pull request #40 from lucletoffe/fix/calendar-date-paging
fix(calendar): page a server-side date filter through CalendarEvent/query
This commit is contained in:
@@ -42,10 +42,12 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('queryEvents', () => {
|
||||
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter (and
|
||||
// after/before); calendars are restricted via singular `inCalendar`
|
||||
// conditions and date filtering stays client-side.
|
||||
it('should AND the date window with the singular inCalendar condition', async () => {
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter, so calendars
|
||||
// are restricted via singular `inCalendar` conditions. The date window
|
||||
// must reach the server: without it the query asks for every event in
|
||||
// the account and the response is capped at the page size, so the
|
||||
// calendar renders empty for anyone with more than one page of history.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
@@ -55,10 +57,38 @@ describe('calendar operations', () => {
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[0]).toBe('CalendarEvent/query');
|
||||
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inCalendar: 'cal-1' },
|
||||
{ after: '2026-03-01T00:00:00Z', before: '2026-03-31T23:59:59Z' },
|
||||
],
|
||||
});
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('should page through a result set larger than one page', async () => {
|
||||
// Regression: a full page used to be returned as the complete answer, so
|
||||
// an account with more events than the page size silently lost every
|
||||
// event past it - the calendar rendered empty.
|
||||
const first = Array.from({ length: 1000 }, (_, i) => `a${i}`);
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/query', { ids: first }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']],
|
||||
});
|
||||
|
||||
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
|
||||
|
||||
expect(result).toHaveLength(1002);
|
||||
expect(result[1001]).toBe('tail-2');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0);
|
||||
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000);
|
||||
});
|
||||
|
||||
it('should OR multiple calendars and respect the target account', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
@@ -74,7 +104,7 @@ describe('calendar operations', () => {
|
||||
expect(call[1].accountId).toBe('acc-shared');
|
||||
});
|
||||
|
||||
it('should omit the filter when no calendars are given', async () => {
|
||||
it('should omit the filter when no calendars are given and window is empty', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
+58
-14
@@ -185,26 +185,70 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
return [...own, ...shared.flat()];
|
||||
}
|
||||
|
||||
const QUERY_PAGE_SIZE = 1000;
|
||||
// Backstop against a server that keeps returning full pages; 20 pages is far
|
||||
// beyond any real calendar window and keeps a bug from looping forever.
|
||||
const QUERY_MAX_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Combine the calendar restriction and the date window into a single filter.
|
||||
* Stalwart matches an event when its whole time range - including every
|
||||
* recurrence - overlaps the window, which is exactly the set the client-side
|
||||
* expander needs (a recurring master whose base start predates the window
|
||||
* still comes back if any occurrence falls inside). Either part may be absent
|
||||
* (callers that want every event in an account pass empty bounds).
|
||||
*/
|
||||
function buildEventFilter(
|
||||
calendarIds: string[],
|
||||
after: string,
|
||||
before: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||
const window: Record<string, unknown> = {};
|
||||
if (after) window.after = after;
|
||||
if (before) window.before = before;
|
||||
if (Object.keys(window).length > 0) conditions.push(window);
|
||||
if (conditions.length === 0) return undefined;
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
export async function queryEvents(
|
||||
calendarIds: string[],
|
||||
_after: string,
|
||||
_before: string,
|
||||
after: string,
|
||||
before: string,
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
// Stalwart rejects after/before filters on CalendarEvent/query; date
|
||||
// filtering is done client-side. Calendars are restricted via the singular
|
||||
// `inCalendar` condition (the plural `inCalendars` fails the whole query
|
||||
// with unsupportedFilter on Stalwart).
|
||||
// The date window is applied server-side. Without it the query asks for
|
||||
// every event in the account and the response is silently capped at the
|
||||
// page size, so any account holding more events than one page loses
|
||||
// everything past it - including the month being displayed. Calendars are
|
||||
// restricted via the singular `inCalendar` condition (the plural
|
||||
// `inCalendars` fails the whole query with unsupportedFilter on Stalwart).
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
|
||||
const timeZone = getUserTimeZone();
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
const filter = buildEventFilter(calendarIds, after, before);
|
||||
const ids: string[] = [];
|
||||
// Page through the result set: a window can still hold more events than one
|
||||
// page, and a truncated page must never pass for a complete answer.
|
||||
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
|
||||
const args: Record<string, unknown> = {
|
||||
accountId: account,
|
||||
limit: QUERY_PAGE_SIZE,
|
||||
position: ids.length,
|
||||
};
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (filter) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
ids.push(...batch);
|
||||
if (batch.length < QUERY_PAGE_SIZE) break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
|
||||
|
||||
Reference in New Issue
Block a user