mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
# 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
592 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|