mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ session: null as unknown }));
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
request: vi.fn(),
|
||||||
|
getMaxObjectsInGet: () => 500,
|
||||||
|
getMaxCallsInRequest: () => 16,
|
||||||
|
get currentSession() {
|
||||||
|
return h.session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../jmap-client';
|
||||||
|
import { queryEvents, queryExpandedEvents, resetTimeZoneChecks } from '../calendar';
|
||||||
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
|
||||||
|
// Stalwart 0.16 cannot place a range bound whose wall clock names no instant
|
||||||
|
// in the request's zone (the hour the clocks skip) or two (the hour they
|
||||||
|
// repeat): an expanded query fails with "Both 'after' and 'before' filters
|
||||||
|
// are required when expanding recurrences", a plain one drops the bound
|
||||||
|
// (live on 0.16.27). Such a bound goes out moved out of the range.
|
||||||
|
|
||||||
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function connect(maxExpandedQueryDuration = 'P52W1D'): void {
|
||||||
|
h.session = {
|
||||||
|
apiUrl: 'https://mail.example.org/jmap/',
|
||||||
|
accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': { maxExpandedQueryDuration } } } },
|
||||||
|
};
|
||||||
|
mockRequest.mockImplementation(async (calls: [string, unknown, string][]) => ({
|
||||||
|
methodResponses: calls.map(([name, , id]) => [name, { ids: [] }, id]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = () => mockRequest.mock.calls.flatMap(([calls]) =>
|
||||||
|
(calls as [string, { filter?: Record<string, unknown> }, string][])
|
||||||
|
.filter(([name]) => name === 'CalendarEvent/query')
|
||||||
|
.map(([, args]) => {
|
||||||
|
const filter = args.filter as { after?: string; before?: string; conditions?: { after?: string; before?: string }[] };
|
||||||
|
const range = filter.conditions?.find((c) => 'after' in c) ?? filter;
|
||||||
|
return [range.after, range.before];
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetTimeZoneChecks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'auto' });
|
||||||
|
h.session = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('range bounds next to a change of the clocks', () => {
|
||||||
|
// Havana turns the clocks back from 01:00 to 00:00 on 7 November 2027:
|
||||||
|
// the week from that Sunday starts at a midnight that happens twice.
|
||||||
|
const HAVANA_WEEK = { after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z' };
|
||||||
|
|
||||||
|
it('moves an expanded query\'s start out of the hour that repeats', async () => {
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'America/Havana' });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
await queryExpandedEvents(HAVANA_WEEK.after, HAVANA_WEEK.before);
|
||||||
|
|
||||||
|
expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a plain query\'s start out of the hour that repeats', async () => {
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'America/Havana' });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
await queryEvents(['cal-1'], HAVANA_WEEK.after, HAVANA_WEEK.before);
|
||||||
|
|
||||||
|
expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps chunk borders out of the hour the clocks skip, still overlapping', async () => {
|
||||||
|
// Santiago skips from 00:00 to 01:00 on 5 September 2027. With a limit
|
||||||
|
// of two days the chunks are a day long and their borders fall at 00:30.
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'America/Santiago' });
|
||||||
|
connect('P2D');
|
||||||
|
|
||||||
|
await queryExpandedEvents('2027-09-04T04:30:00.000Z', '2027-09-06T04:30:00.000Z');
|
||||||
|
|
||||||
|
expect(bounds()).toEqual([
|
||||||
|
['2027-09-04T00:30:00', '2027-09-05T01:00:00'],
|
||||||
|
['2027-09-04T23:59:59', '2027-09-06T00:29:59'],
|
||||||
|
['2027-09-06T00:29:58', '2027-09-06T01:30:00'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bounds that name one instant as they are', async () => {
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Berlin turns the clocks back at 03:00; its midnights are unique.
|
||||||
|
await queryExpandedEvents('2027-10-30T22:00:00.000Z', '2027-11-06T23:00:00.000Z');
|
||||||
|
|
||||||
|
expect(bounds()).toEqual([['2027-10-31T00:00:00', '2027-11-07T00:00:00']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+53
-4
@@ -2,7 +2,7 @@ import { jmapClient } from './jmap-client';
|
|||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import type { Calendar, CalendarEvent, CalendarRights } from './types';
|
import type { Calendar, CalendarEvent, CalendarRights } from './types';
|
||||||
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
|
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
|
||||||
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
|
import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone';
|
||||||
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||||
import {
|
import {
|
||||||
RECURRENCE_BASE_PROPERTIES,
|
RECURRENCE_BASE_PROPERTIES,
|
||||||
@@ -509,8 +509,8 @@ export async function queryEvents(
|
|||||||
const conditions: Record<string, unknown>[] = [];
|
const conditions: Record<string, unknown>[] = [];
|
||||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||||
const range: Record<string, string> = {};
|
const range: Record<string, string> = {};
|
||||||
if (after) range.after = toLocalDateTime(after, timeZone);
|
if (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1);
|
||||||
if (before) range.before = toLocalDateTime(before, timeZone);
|
if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1);
|
||||||
if (Object.keys(range).length > 0) conditions.push(range);
|
if (Object.keys(range).length > 0) conditions.push(range);
|
||||||
const filter = conditions.length === 1
|
const filter = conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
@@ -589,6 +589,52 @@ function localDateTimeOf(seconds: number): string {
|
|||||||
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
|
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stalwart 0.16 cannot place a range bound whose wall clock names no instant
|
||||||
|
// in the request's time zone (in the hour the clocks skip) or two (in the
|
||||||
|
// hour they repeat): an expanded query fails with "Both 'after' and 'before'
|
||||||
|
// filters are required when expanding recurrences", and a plain one leaves
|
||||||
|
// the bound out (live on 0.16.27; 1.0 places both). Where the clocks change
|
||||||
|
// at midnight - Havana, the Azores, Santiago - the start of a day is such a
|
||||||
|
// time, and a chunk border can be one anywhere. Such a bound moves out of
|
||||||
|
// the range until it names one instant: `after` earlier, `before` later.
|
||||||
|
const BOUND_STEP = 15 * 60;
|
||||||
|
const MAX_BOUND_SHIFT = 3 * 3600;
|
||||||
|
|
||||||
|
/** How many instants the wall clock `seconds` (wallClockSeconds) names in `timeZone`. */
|
||||||
|
function instantsAt(seconds: number, timeZone: string): number {
|
||||||
|
const wall = localDateTimeOf(seconds);
|
||||||
|
// The offsets a day either side: a change of the clocks in between is
|
||||||
|
// the only one near.
|
||||||
|
const offsets = new Set([seconds - DAY_SECONDS, seconds + DAY_SECONDS].map(
|
||||||
|
(at) => getTimeZoneOffsetMs(new Date(at * 1000), timeZone) / 1000,
|
||||||
|
));
|
||||||
|
let count = 0;
|
||||||
|
for (const offset of offsets) {
|
||||||
|
if (formatWallClock(new Date((seconds - offset) * 1000), timeZone) === wall) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wall clock `seconds`, or the nearest one in `direction` that names one instant in `timeZone`. */
|
||||||
|
function placeableBound(seconds: number, timeZone: string | undefined, direction: -1 | 1): number {
|
||||||
|
if (!timeZone || timeZone === 'UTC') return seconds;
|
||||||
|
try {
|
||||||
|
for (let shift = 0; shift <= MAX_BOUND_SHIFT; shift += BOUND_STEP) {
|
||||||
|
const candidate = seconds + direction * shift;
|
||||||
|
if (instantsAt(candidate, timeZone) === 1) return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A zone Intl does not know either: the server has the last word.
|
||||||
|
}
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** placeableBound of a LocalDateTime; anything else as it is. */
|
||||||
|
function placeableLocalBound(value: string, timeZone: string | undefined, direction: -1 | 1): string {
|
||||||
|
const seconds = wallClockSeconds(value);
|
||||||
|
return seconds === null ? value : localDateTimeOf(placeableBound(seconds, timeZone, direction));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [start, end] cut into chunks of at most `span` seconds, or null when that
|
* [start, end] cut into chunks of at most `span` seconds, or null when that
|
||||||
* takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
|
* takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
|
||||||
@@ -735,7 +781,10 @@ async function queryExpandedEventsIn(
|
|||||||
'CalendarEvent/query',
|
'CalendarEvent/query',
|
||||||
{
|
{
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
filter: { after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) },
|
filter: {
|
||||||
|
after: localDateTimeOf(placeableBound(chunk.start, timeZone, -1)),
|
||||||
|
before: localDateTimeOf(placeableBound(chunk.end, timeZone, 1)),
|
||||||
|
},
|
||||||
position: chunk.position,
|
position: chunk.position,
|
||||||
limit: QUERY_PAGE_SIZE,
|
limit: QUERY_PAGE_SIZE,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user