Files
webmail/lib/__tests__/timezone.test.ts
T
dealerweb f5fb7f9db9 feat: out of office forwarding and recipients, filter rule periods
Filter rules can be limited to a period: the rule dialog takes a start
and an end on the user's clock, the rule list shows whether a rule is
planned, active or expired, and the script tests the moment of
delivery with RFC 5260 currentdate in UTC. A rule is never written
without its period.

The vacation card becomes an out of office page: the period on top,
then the automatic reply and forwarding, each with its own switch.
Forwarding runs with or without the auto-reply, within the period,
never for spam, and keeps a copy here or not. Against the server's
redirect limit it counts as a rule ahead of all others, one that stops
the message unless it keeps a copy. The auto-reply can go to
internal or external senders only, told apart by the envelope sender
against the account's own domains. Both run from the filters script
that includes Stalwart's vacation script, which is put back in charge
when the server's own script takes over.

A script that uses any of this says metadata version 2, which older
builds leave alone instead of writing it back without these fields.
2026-10-02 11:29:32 +02:00

194 lines
7.8 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import {
AUTO_TIME_ZONE,
displayNow,
fromDisplayDate,
fromWallClock,
fromZonedDisplayDate,
getBrowserTimeZone,
getTimeZoneOffsetMs,
getWallClock,
isDisplayToday,
isValidTimeZone,
listTimeZones,
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');
});
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('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);
});
it('reads a wall-clock in a zone straight from its fields', () => {
const at = (year: number, month: number, day: number, hour: number, minute: number, zone: string) =>
fromWallClock({ year, month, day, hour, minute, second: 0 }, zone).toISOString();
expect(at(2026, 10, 5, 8, 0, 'Europe/Berlin')).toBe('2026-10-05T06:00:00.000Z');
expect(at(2026, 10, 26, 18, 0, 'Europe/Berlin')).toBe('2026-10-26T17:00:00.000Z');
// 02:30 on 29 March is skipped in Berlin, but exists in New York.
expect(at(2026, 3, 29, 2, 30, 'America/New_York')).toBe('2026-03-29T06:30:00.000Z');
});
});
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('');
});
});