Files
webmail/lib/__tests__/timezone.test.ts
T
Linus Rath a8cccf1ad1 fix: keep occurrence alert edits on 1.0 and count the organizer
Fixes from a second review of the Stalwart 1.0 calendar work.

- Changing the alerts of one occurrence of a series that uses the
  calendar's default alerts said "Event updated" and kept the default
  on 1.0: while a series uses them, 1.0 shows the defaults in place of
  an occurrence's own alerts and refuses `useDefaultAlerts` on a single
  occurrence. The series now stops using the defaults first, keeping
  the alerts it shows under ids of their own, and then the occurrence's
  alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
  1.0: 19 attendees and the organizer are accepted, 20 are refused), so
  the editor warned one attendee late. It warns at the limit now, and
  the warning and the refusal say the organizer counts, with plural
  forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
  proposal, said "could not complete" when the server refused the
  event: the import swallowed its SetError. The banner now asks the
  import to reject with the refusal and names the participant limit
  like the calendar does.
- A range load that finished after a later one could put back its own
  events, range or failure. Only the latest load writes the store, and
  a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
  to its date bounds goes out as it is instead of being expanded, which
  1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
  is used as Intl spells it. 1.0 refused it as a query argument, and
  0.16 and 1.0 both dropped it from new events, which were stored
  floating.
- A failed save no longer shows the store's English toast next to the
  translated message.
- Tests for a limited read that asks later chunks of a request again
  after a refused chunk or a full page.
2026-09-25 21:21:28 +02:00

206 lines
8.6 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import {
AUTO_TIME_ZONE,
displayNow,
fromDisplayDate,
fromZonedDisplayDate,
getBrowserTimeZone,
getTimeZoneOffsetMs,
getWallClock,
isDisplayToday,
isValidTimeZone,
canonicalTimeZone,
listTimeZones,
moveLocalDateTime,
resolveTimeZone,
toDisplayDate,
toZonedDisplayDate,
} from '../timezone';
import { formatDate, formatDateTime } from '../utils';
import { useSettingsStore } from '@/stores/settings-store';
// Pin the process zone so "browser" detection is deterministic - this is the
// LibreWolf / resistFingerprinting scenario from #755: the runtime reports
// UTC, the user actually lives somewhere else.
const ORIGINAL_TZ = process.env.TZ;
beforeAll(() => {
process.env.TZ = 'UTC';
});
afterAll(() => {
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
else process.env.TZ = ORIGINAL_TZ;
});
afterEach(() => {
useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE, dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto' });
vi.useRealTimers();
});
const HOUR = 3600_000;
describe('resolveTimeZone', () => {
it('follows the browser for auto / empty / unknown values', () => {
expect(resolveTimeZone(AUTO_TIME_ZONE, 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone('', 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone(undefined, 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone('Mars/Olympus_Mons', 'Europe/Berlin')).toBe('Europe/Berlin');
});
it('returns a valid explicit zone unchanged', () => {
expect(resolveTimeZone('Asia/Tokyo', 'Europe/Berlin')).toBe('Asia/Tokyo');
});
// Stalwart 1.0 refuses "europe/berlin" as a query's timeZone, and both
// 0.16 and 1.0 drop it from an event, storing the event as floating.
it('spells a zone typed in another case the way Intl does', () => {
expect(resolveTimeZone('europe/berlin', 'UTC')).toBe('Europe/Berlin');
expect(canonicalTimeZone('AMERICA/NEW_YORK')).toBe('America/New_York');
expect(canonicalTimeZone('Mars/Olympus_Mons')).toBeNull();
});
it('validates zone ids against Intl', () => {
expect(isValidTimeZone('America/New_York')).toBe(true);
expect(isValidTimeZone('Not/A_Zone')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
expect(isValidTimeZone(42)).toBe(false);
});
it('lists zones including the common ones', () => {
const zones = listTimeZones();
expect(zones).toContain('Europe/Berlin');
expect(zones[0]).toBe('UTC');
expect(zones.filter((z) => z === 'UTC')).toHaveLength(1);
const rest = zones.slice(1);
expect([...rest].sort()).toEqual(rest);
});
});
describe('wall-clock helpers', () => {
const noon = new Date('2026-07-01T12:00:00Z');
it('reads the wall-clock of an instant in a zone', () => {
expect(getWallClock(noon, 'Asia/Tokyo')).toEqual({ year: 2026, month: 7, day: 1, hour: 21, minute: 0, second: 0 });
expect(getWallClock(noon, 'America/Los_Angeles')).toEqual({ year: 2026, month: 7, day: 1, hour: 5, minute: 0, second: 0 });
});
it('computes DST-aware offsets', () => {
expect(getTimeZoneOffsetMs(noon, 'Asia/Tokyo')).toBe(9 * HOUR);
expect(getTimeZoneOffsetMs(noon, 'Europe/Berlin')).toBe(2 * HOUR);
expect(getTimeZoneOffsetMs(new Date('2026-01-15T12:00:00Z'), 'Europe/Berlin')).toBe(1 * HOUR);
expect(getTimeZoneOffsetMs(noon, 'UTC')).toBe(0);
});
it('shifts an instant so local getters read as the zone wall-clock', () => {
const berlin = toZonedDisplayDate(noon, 'Europe/Berlin');
expect(berlin.getHours()).toBe(14);
expect(berlin.getDate()).toBe(1);
// Crosses midnight: 23:30Z on the 1st is 01:30 on the 2nd in Berlin.
const late = toZonedDisplayDate(new Date('2026-07-01T23:30:00Z'), 'Europe/Berlin');
expect(late.getDate()).toBe(2);
expect(late.getHours()).toBe(1);
expect(late.getMinutes()).toBe(30);
});
it('round-trips through fromZonedDisplayDate, including across a DST switch', () => {
const instants = [
'2026-07-01T12:00:00Z',
'2026-07-01T23:30:00Z',
'2026-01-15T00:15:00Z',
'2026-03-29T00:59:00Z', // Berlin: last minute of CET before the spring-forward
'2026-03-29T01:00:00Z', // Berlin: 03:00 CEST, right after the gap
'2026-10-25T01:30:00Z', // Berlin: second 02:30 of the fall-back night
];
for (const iso of instants) {
for (const zone of ['Europe/Berlin', 'America/New_York', 'Asia/Kolkata', 'Pacific/Auckland', 'UTC']) {
const instant = new Date(iso);
const shifted = toZonedDisplayDate(instant, zone);
expect(fromZonedDisplayDate(shifted, zone).getTime(), `${iso} in ${zone}`).toBe(instant.getTime());
}
}
});
it('moves a LocalDateTime from one zone to another at the same instant', () => {
expect(moveLocalDateTime('2026-09-01T00:00:00', 'Europe/Berlin', 'UTC')).toBe('2026-08-31T22:00:00');
expect(moveLocalDateTime('2026-09-30T23:59:59', 'Asia/Tokyo', 'UTC')).toBe('2026-09-30T14:59:59');
// Just after the spring-forward gap in New York (EDT, UTC-4).
expect(moveLocalDateTime('2026-03-08T03:30:00', 'America/New_York', 'UTC')).toBe('2026-03-08T07:30:00');
expect(moveLocalDateTime('2026-09-01T00:00:00', 'UTC', 'Asia/Tokyo')).toBe('2026-09-01T09:00:00');
// Same zone, a value with an offset, and garbage stay as they are.
expect(moveLocalDateTime('2026-09-01T00:00:00', 'Asia/Tokyo', 'Asia/Tokyo')).toBe('2026-09-01T00:00:00');
expect(moveLocalDateTime('2026-09-01T00:00:00Z', 'Asia/Tokyo', 'UTC')).toBe('2026-09-01T00:00:00Z');
expect(moveLocalDateTime('soon', 'Asia/Tokyo', 'UTC')).toBe('soon');
});
it('keeps invalid dates invalid instead of throwing', () => {
const bad = new Date('nope');
expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
expect(isNaN(fromZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
});
});
describe('store-aware display dates', () => {
it('are the identity while the setting is auto', () => {
expect(getBrowserTimeZone()).toBe('UTC');
const d = new Date('2026-07-01T12:00:00Z');
expect(toDisplayDate(d)).toBe(d);
expect(fromDisplayDate(d)).toBe(d);
});
it('shift by the override zone and invert cleanly', () => {
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
const d = new Date('2026-07-01T12:00:00Z');
const display = toDisplayDate(d);
expect(display.getHours()).toBe(21);
expect(display.getTime() - d.getTime()).toBe(9 * HOUR);
expect(fromDisplayDate(display).getTime()).toBe(d.getTime());
});
it('report "today" from the override zone, not the browser zone', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); // 01:00 on July 2 in Tokyo
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
const now = displayNow();
expect(now.getDate()).toBe(2);
expect(now.getHours()).toBe(1);
expect(isDisplayToday(new Date(2026, 6, 2, 9, 0))).toBe(true);
expect(isDisplayToday(new Date(2026, 6, 1, 23, 0))).toBe(false);
});
it('fall back to the browser zone for an unknown stored zone', () => {
useSettingsStore.setState({ timeZone: 'Mars/Olympus_Mons' });
const d = new Date('2026-07-01T12:00:00Z');
expect(toDisplayDate(d)).toBe(d);
});
});
describe('mail timestamp formatting honours the override', () => {
it('formatDate buckets "today" in the override zone', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); // 01:00 July 2 in Tokyo
const received = new Date('2026-07-01T14:00:00Z'); // 23:00 July 1 in Tokyo
// Browser zone (UTC): same day -> time only.
expect(formatDate(received)).toBe('14:00');
// Tokyo: yesterday -> short weekday + time, in Tokyo wall-clock.
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
expect(formatDate(received)).toBe('Wed 23:00');
});
it('formatDate full and formatDateTime render the override wall-clock', () => {
useSettingsStore.setState({ timeZone: 'Asia/Tokyo', dateFormat: 'full' });
const received = new Date('2026-07-01T14:00:00Z');
expect(formatDate(received)).toContain('23:00');
expect(formatDateTime(received, '24h', { year: 'numeric', month: 'short', day: 'numeric' })).toContain('23:00');
});
it.each(['smart', 'relative', 'full'] as const)('formatDate returns "" for an invalid date in %s mode (#1099)', (dateFormat) => {
useSettingsStore.setState({ timeZone: 'Asia/Tokyo', dateFormat });
expect(formatDate('not-a-valid-date')).toBe('');
expect(formatDate('10000-01-01T00:00:00Z')).toBe('');
expect(formatDate(new Date('nope'))).toBe('');
expect(formatDate(undefined as unknown as string)).toBe('');
});
});