Files
webmail/lib/__tests__/calendar-alerts.test.ts
T
Linus Rath cf2c27157d Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	components/contacts/contacts-app.tsx
#	components/email/__tests__/composer-discard.test.tsx
#	components/email/calendar-invitation-banner.tsx
#	components/email/email-composer.tsx
#	components/mail/mail-app.tsx
#	components/pro/pro-compose-tab-body.tsx
#	components/pro/pro-email-tab-body.tsx
#	components/ui/__tests__/avatar.test.tsx
#	components/ui/avatar.tsx
#	hooks/use-mailbox-drop.ts
#	lib/__tests__/calendar-alerts.test.ts
#	lib/__tests__/jmap-calendar-tasks.test.ts
#	lib/__tests__/jmap-send-threading.test.ts
#	lib/demo/demo-client.ts
#	lib/demo/fixtures/calendars.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/web-push.ts
#	stores/__tests__/contact-store.test.ts
#	stores/__tests__/email-store-thread-key.test.ts
#	stores/calendar-store.ts
#	stores/contact-store.ts
#	stores/email-store.ts
2026-10-26 10:14:12 +01:00

592 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
parseAlertOffset,
computeFireTime,
getEffectiveAlerts,
buildAlertKey,
getPendingAlerts,
getPendingTaskAlerts,
withOwnAlertIds,
getCalendarDefaultAlerts,
offsetToAlertUnit,
describeAlert,
alertsToRows,
rowsToAlerts,
newAlertRow,
computeTaskFireTime,
} from '../calendar-alerts';
import type {
CalendarEvent,
CalendarEventAlert,
CalendarTask,
Calendar,
} from '@/lib/jmap/types';
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
calendarIds: { 'cal-1': true },
isDraft: false,
isOrigin: true,
utcStart: '2026-03-01T10:00:00Z',
utcEnd: '2026-03-01T11:00:00Z',
'@type': 'Event',
uid: 'uid-1',
title: 'Test Event',
description: '',
descriptionContentType: 'text/plain',
created: null,
updated: '2026-03-01T09:00:00Z',
sequence: 0,
start: '2026-03-01T10:00:00',
duration: 'PT1H',
timeZone: 'UTC',
showWithoutTime: false,
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
...overrides,
};
}
function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
return {
id: 'cal-1',
name: 'Work',
description: null,
color: '#0000ff',
sortOrder: 1,
isSubscribed: true,
isVisible: true,
isDefault: true,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: false,
mayDelete: false,
},
...overrides,
};
}
function makeAlert(overrides: Partial<CalendarEventAlert> = {}): CalendarEventAlert {
return {
'@type': 'Alert',
trigger: {
'@type': 'OffsetTrigger',
offset: '-PT5M',
relativeTo: 'start',
},
action: 'display',
acknowledged: null,
relatedTo: null,
...overrides,
};
}
describe('parseAlertOffset', () => {
it('parses negative minutes', () => {
expect(parseAlertOffset('-PT5M')).toBe(-5 * 60 * 1000);
});
it('parses positive minutes', () => {
expect(parseAlertOffset('PT10M')).toBe(10 * 60 * 1000);
});
it('parses zero duration', () => {
expect(parseAlertOffset('PT0S')).toBe(0);
});
it('parses hours', () => {
expect(parseAlertOffset('-PT1H')).toBe(-60 * 60 * 1000);
});
it('parses days', () => {
expect(parseAlertOffset('-P1D')).toBe(-24 * 60 * 60 * 1000);
});
it('parses complex duration', () => {
expect(parseAlertOffset('-P1DT2H30M')).toBe(-(24 * 60 * 60 + 2 * 60 * 60 + 30 * 60) * 1000);
});
it('returns null for invalid format', () => {
expect(parseAlertOffset('invalid')).toBeNull();
});
it('parses positive day duration', () => {
expect(parseAlertOffset('P2D')).toBe(2 * 24 * 60 * 60 * 1000);
});
it('parses seconds only', () => {
expect(parseAlertOffset('PT30S')).toBe(30 * 1000);
});
});
describe('computeFireTime', () => {
it('computes offset from utcStart', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT5M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00Z').getTime() - 5 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('falls back to start when utcStart is null', () => {
const event = makeEvent({ utcStart: null, start: '2026-03-01T10:00:00' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT10M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00').getTime() - 10 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('handles absolute trigger', () => {
const event = makeEvent();
const trigger = { '@type': 'AbsoluteTrigger' as const, when: '2026-03-01T09:55:00Z' };
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T09:55:00Z').getTime());
});
it('handles zero offset (at time of event)', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT0S', relativeTo: 'start' as const };
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T10:00:00Z').getTime());
});
it('handles positive offset (after start)', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT15M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00Z').getTime() + 15 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('computes offset from utcEnd when relativeTo is end', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z', utcEnd: '2026-03-01T11:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT5M', relativeTo: 'end' as const };
const expected = new Date('2026-03-01T11:00:00Z').getTime() + 5 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('returns null for invalid offset', () => {
const event = makeEvent();
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'garbage', relativeTo: 'start' as const };
expect(computeFireTime(event, trigger)).toBeNull();
});
it('returns null for invalid absolute trigger date', () => {
const event = makeEvent();
const trigger = { '@type': 'AbsoluteTrigger' as const, when: 'not-a-date' };
expect(computeFireTime(event, trigger)).toBeNull();
});
});
describe('getEffectiveAlerts', () => {
it('returns event alerts when useDefaultAlerts is false', () => {
const alerts = { 'a1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: false, alerts });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBe(alerts);
});
it('returns null when event has no alerts and useDefaultAlerts is false', () => {
const event = makeEvent({ useDefaultAlerts: false, alerts: null });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
it('returns calendar defaultAlertsWithTime for timed events', () => {
const defaultAlerts = { 'd1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: false });
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
});
it('returns calendar defaultAlertsWithoutTime for all-day events', () => {
const defaultAlerts = { 'd1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: true });
const calendars = [makeCalendar({ defaultAlertsWithoutTime: defaultAlerts })];
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
});
it('prefers the alerts the server copied into a defaults event over the current calendar defaults', () => {
const copied = { k1: makeAlert({ action: 'email' }) };
const event = makeEvent({ useDefaultAlerts: true, alerts: copied });
const calendars = [makeCalendar({ defaultAlertsWithTime: { d1: makeAlert() } })];
expect(getEffectiveAlerts(event, calendars)).toBe(copied);
});
it('returns null when calendar not found', () => {
const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
it('returns null when calendarIds is empty', () => {
const event = makeEvent({ useDefaultAlerts: true, calendarIds: {} });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
});
describe('getCalendarDefaultAlerts', () => {
const withTime = { d1: makeAlert() };
const withoutTime = { d2: makeAlert({ action: 'email' }) };
const calendar = makeCalendar({ defaultAlertsWithTime: withTime, defaultAlertsWithoutTime: withoutTime });
it('picks the timed map for timed events and the all-day map for all-day events', () => {
expect(getCalendarDefaultAlerts(calendar, false)).toBe(withTime);
expect(getCalendarDefaultAlerts(calendar, true)).toBe(withoutTime);
});
it('returns null for a missing calendar or an empty map', () => {
expect(getCalendarDefaultAlerts(undefined, false)).toBeNull();
expect(getCalendarDefaultAlerts(makeCalendar({ defaultAlertsWithTime: {} }), false)).toBeNull();
});
});
describe('offsetToAlertUnit', () => {
it('reads offsets before the start', () => {
expect(offsetToAlertUnit('-PT15M')).toEqual({ value: 15, unit: 'minutes' });
expect(offsetToAlertUnit('-PT120M')).toEqual({ value: 2, unit: 'hours' });
expect(offsetToAlertUnit('-PT9H')).toEqual({ value: 9, unit: 'hours' });
expect(offsetToAlertUnit('-P2D')).toEqual({ value: 2, unit: 'days' });
expect(offsetToAlertUnit('-P1W')).toEqual({ value: 1, unit: 'weeks' });
expect(offsetToAlertUnit('PT0S')).toEqual({ value: 0, unit: 'at_time' });
});
it('refuses offsets after the start and compound offsets', () => {
expect(offsetToAlertUnit('PT15M')).toBeNull();
expect(offsetToAlertUnit('PT9H')).toBeNull();
expect(offsetToAlertUnit('-P1DT2H')).toBeNull();
});
});
describe('describeAlert', () => {
it('keeps the action of display and email alerts', () => {
expect(describeAlert(makeAlert())).toEqual({ value: 5, unit: 'minutes', action: 'display' });
expect(describeAlert(makeAlert({ action: 'email' }))).toEqual({ value: 5, unit: 'minutes', action: 'email' });
});
it('accepts a trigger without relativeTo, as Stalwart returns it', () => {
const alert = makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } as CalendarEventAlert['trigger'] });
expect(describeAlert(alert)).toEqual({ value: 1, unit: 'hours', action: 'display' });
});
it('returns null for alerts the rows cannot show', () => {
expect(describeAlert(makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' } }))).toBeNull();
expect(describeAlert(makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' } }))).toBeNull();
expect(describeAlert(makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'start' } }))).toBeNull();
});
});
describe('alertsToRows / rowsToAlerts', () => {
it('round-trips rows and keeps the alerts it cannot show unchanged', () => {
const absolute = makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' }, action: 'email' });
const afterEnd = makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: 'PT10M', relativeTo: 'end' } });
const { rows, preserved } = alertsToRows({
a: makeAlert({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-P1D', relativeTo: 'start' } }),
abs: absolute,
end: afterEnd,
});
expect(rows.map(({ value, unit, action }) => ({ value, unit, action }))).toEqual([{ value: 1, unit: 'days', action: 'email' }]);
expect(preserved).toEqual({ abs: absolute, end: afterEnd });
const saved = rowsToAlerts(rows, preserved);
expect(saved.abs).toBe(absolute);
expect(saved.end).toBe(afterEnd);
const built = Object.entries(saved).filter(([k]) => k !== 'abs' && k !== 'end');
expect(built).toHaveLength(1);
expect(built[0][1]).toMatchObject({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-P1D', relativeTo: 'start' } });
});
it('writes each row with its own action and skips rows without an amount', () => {
const saved = rowsToAlerts([newAlertRow(10, 'minutes'), newAlertRow(2, 'hours', 'email'), newAlertRow(0, 'minutes')]);
expect(Object.values(saved).map((a) => [a.action, (a.trigger as { offset: string }).offset])).toEqual([
['display', '-PT10M'],
['email', '-PT2H'],
]);
});
it('does not overwrite a preserved alert that already uses a generated key', () => {
const kept = makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' } });
const saved = rowsToAlerts([newAlertRow(5, 'minutes')], { alert1: kept });
expect(saved.alert1).toBe(kept);
expect(saved.alert2).toMatchObject({ trigger: { offset: '-PT5M' } });
});
});
describe('buildAlertKey', () => {
it('builds deterministic key', () => {
const key = buildAlertKey('evt-1', 'alert-1', 12345);
expect(key).toBe('evt-1:alert-1:12345');
});
});
describe('getPendingAlerts', () => {
const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
const fiveMinBefore = eventStart - 5 * 60 * 1000;
it('returns pending display alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].eventId).toBe('evt-1');
expect(result[0].alertId).toBe('a1');
expect(result[0].calendarName).toBe('Work');
});
it('skips alerts not yet due', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore - 10000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips stale alerts older than 10 minutes', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 11 * 60 * 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips alerts at exactly the stale threshold', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 10 * 60 * 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips acknowledged alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const key = buildAlertKey('evt-1', 'a1', fiveMinBefore);
const result = getPendingAlerts([event], calendars, new Set([key]), now);
expect(result).toHaveLength(0);
});
it('skips alerts for cancelled events', () => {
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead
// of deleting it (#572) - its reminders must not fire.
const event = makeEvent({
status: 'cancelled',
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips email action alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert({ action: 'email' }) },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips server-acknowledged alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('resolves useDefaultAlerts from calendar defaults', () => {
const defaultAlerts = {
'd1': makeAlert({
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
}),
};
const event = makeEvent({
useDefaultAlerts: true,
alerts: null,
});
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
const fifteenMinBefore = eventStart - 15 * 60 * 1000;
const now = fifteenMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].alertId).toBe('d1');
});
it('returns multiple alerts from same event independently', () => {
const tenMinBefore = eventStart - 10 * 60 * 1000;
const event = makeEvent({
alerts: {
'a1': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }),
'a2': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }),
},
});
const calendars = [makeCalendar()];
const now = tenMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].alertId).toBe('a2');
const nowLater = fiveMinBefore + 1000;
const result2 = getPendingAlerts([event], calendars, new Set(), nowLater);
expect(result2).toHaveLength(2);
const alertIds = result2.map(r => r.alertId).sort();
expect(alertIds).toEqual(['a1', 'a2']);
});
it('handles multiple events with mixed alert states', () => {
const evt1 = makeEvent({
id: 'evt-1',
alerts: { 'a1': makeAlert() },
});
const evt2 = makeEvent({
id: 'evt-2',
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
});
const evt3 = makeEvent({
id: 'evt-3',
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const key1 = buildAlertKey('evt-1', 'a1', fiveMinBefore);
const result = getPendingAlerts([evt1, evt2, evt3], calendars, new Set([key1]), now);
expect(result).toHaveLength(1);
expect(result[0].eventId).toBe('evt-3');
});
it('skips alerts with unparseable offsets', () => {
const event = makeEvent({
alerts: {
'a1': makeAlert({
trigger: { '@type': 'OffsetTrigger', offset: 'invalid', relativeTo: 'start' },
}),
},
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('returns calendarName as null when calendar not found', () => {
const event = makeEvent({
calendarIds: { 'missing': true },
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].calendarName).toBeNull();
});
});
// Stalwart 1.0 fills a calendar's default alerts into `alerts` while
// `useDefaultAlerts` is on, under the default alerts' own ids (for tasks as
// well). 0.16 copied the defaults into `alerts` once, when the event was
// created. Either way each alert fires once.
describe('default alerts filled in by the server', () => {
const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
const defaults = { 'default-5': makeAlert(), 'default-mail': makeAlert({ action: 'email' }) };
const calendars = [makeCalendar({ defaultAlertsWithTime: defaults })];
it('fires each default alert of an event once on Stalwart 1.0', () => {
const event = makeEvent({ useDefaultAlerts: true, alerts: { ...defaults } });
const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
});
it('fires each default alert of an event once on 0.16', () => {
// Copied in at create time, under ids of 0.16's own. The copy is what the
// server and other clients fire, so it is what fires here too.
const event = makeEvent({ useDefaultAlerts: true, alerts: { copied: makeAlert() } });
const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['copied']);
});
it('fires the default alerts filled into a task once on Stalwart 1.0', () => {
const task = {
id: 'task-1', '@type': 'Task', title: 'Pay rent', calendarIds: { 'cal-1': true },
due: '2026-03-01T10:00:00Z', progress: 'needs-action', useDefaultAlerts: true, alerts: { ...defaults },
} as unknown as CalendarTask;
const pending = getPendingTaskAlerts([task], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
});
});
describe('withOwnAlertIds', () => {
it('numbers the alerts past the ids that are taken', () => {
const [a, b] = [makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }), makeAlert()];
expect(withOwnAlertIds([a, b], ['alert1', 'default-5'])).toEqual({ alert2: a, alert3: b });
expect(withOwnAlertIds([], ['alert1'])).toEqual({});
});
});
describe('computeTaskFireTime (#1328)', () => {
const task = (start: string | null, due: string | null) => ({ start, due } as CalendarTask);
const offset = (relativeTo?: 'start' | 'end') => ({ '@type': 'OffsetTrigger' as const, offset: '-PT5M', ...(relativeTo ? { relativeTo } : {}) }) as Parameters<typeof computeTaskFireTime>[1];
const at = (s: string) => new Date(s).getTime();
it('counts a start-relative alert from the start', () => {
expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset('start'))).toBe(at('2026-10-23T19:55:00Z'));
});
it('treats a missing relativeTo as start, as Stalwart sends it', () => {
expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset())).toBe(at('2026-10-23T19:55:00Z'));
});
it('counts an end-relative alert from the due time', () => {
expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset('end'))).toBe(at('2026-10-23T20:10:00Z'));
});
it('falls back to the due time when the task has no start', () => {
expect(computeTaskFireTime(task(null, '2026-10-23T20:15:00Z'), offset('start'))).toBe(at('2026-10-23T20:10:00Z'));
expect(computeTaskFireTime(task(null, null), offset('start'))).toBeNull();
});
});