Files
Lucas Berger d18aba7816 test(11-05): RED — WR-01 positive-duration TRIGGER classifies as custom
4 new tests in classifyValarms suite asserting TRIGGER:+PT15M and TRIGGER:PT30M
(positive/no-sign = fires after event) classify as {kind:'custom'}, not as
preset/offlist. Negative triggers regression guards also present.
2 tests FAIL (RED): Math.abs() discards the sign, misclassifies as preset.
2026-06-14 08:15:06 -04:00

507 lines
20 KiB
TypeScript

/**
* RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07, CAL-13, CAL-14)
*
* Behaviors under test:
* 1. buildVeventString produces a VCALENDAR with a VEVENT for a timed event
* with DTSTART using UTC 'Z' suffix (D-13 contract — no TZID)
* 2. buildVeventString produces a VCALENDAR with DTSTART as a DATE value
* (no time component, no TZID) for all-day events (D-13, Pitfall 3)
* 3. buildVeventString with rruleString produces a VCALENDAR with an RRULE property (CAL-07)
* 4. buildTimedValarm / buildAllDayValarm VALARM builders (CAL-13, Phase 11 Plan 01)
* 5. classifyValarms / extractValarms (CAL-14, Phase 11 Plan 01)
* 6. computeAlertInstantUtc DST-correct 9 AM-local→UTC (NOTIF-06, Phase 11 Plan 01)
*/
import ICAL from 'ical.js';
import { describe, it, expect } from 'vitest';
import {
buildVeventString,
buildTimedValarm,
buildAllDayValarm,
classifyValarms,
extractValarms,
computeAlertInstantUtc,
} from '../../src/broker/vevent.js';
describe('buildVeventString', () => {
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
const result = buildVeventString({
summary: 'Team standup',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T09:30:00Z'),
});
expect(result).toHaveProperty('uid');
expect(result).toHaveProperty('icsString');
expect(result.icsString).toContain('BEGIN:VCALENDAR');
expect(result.icsString).toContain('BEGIN:VEVENT');
expect(result.icsString).toContain('SUMMARY:Team standup');
expect(result.icsString).toContain('END:VEVENT');
expect(result.icsString).toContain('END:VCALENDAR');
});
it('produces DTSTART with Z suffix (UTC) for a timed event — not TZID', () => {
const result = buildVeventString({
summary: 'Morning meeting',
allDay: false,
dtstart: new Date('2026-06-10T14:00:00Z'),
dtend: new Date('2026-06-10T15:00:00Z'),
});
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
});
it('produces DTSTART as DATE (no time, no TZID) for an all-day event', () => {
const result = buildVeventString({
summary: 'Birthday',
allDay: true,
dtstart: '2026-06-15',
dtend: '2026-06-16',
});
// D-13 all-day contract: VALUE=DATE, no time component, no TZID
// ical.js represents DATE as DTSTART;VALUE=DATE:YYYYMMDD
expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/);
// Must NOT contain a time component (no 'T' after the date)
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/);
// Must NOT contain TZID on the DTSTART property
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
});
it('includes an RRULE property when rruleString is provided (CAL-07)', () => {
const result = buildVeventString({
summary: 'Weekly sync',
allDay: false,
dtstart: new Date('2026-06-09T10:00:00Z'),
dtend: new Date('2026-06-09T11:00:00Z'),
rruleString: 'FREQ=WEEKLY;BYDAY=MO',
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO');
});
it('does NOT include RRULE when rruleString is omitted', () => {
const result = buildVeventString({
summary: 'One-off lunch',
allDay: false,
dtstart: new Date('2026-06-10T12:00:00Z'),
dtend: new Date('2026-06-10T13:00:00Z'),
});
expect(result.icsString).not.toContain('RRULE:');
});
// D-06: RRULE COUNT — verified ical.js 2.2.1 output
it('serializes COUNT in RRULE for a timed event (D-06)', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;COUNT=5',
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5');
});
// D-06: RRULE UNTIL DATE form — all-day event must NOT contain T235959Z
it('serializes UNTIL as DATE form for all-day events (D-06)', () => {
const result = buildVeventString({
summary: 'Daily standup',
allDay: true,
dtstart: '2026-06-10',
dtend: '2026-06-11',
rruleString: 'FREQ=DAILY;UNTIL=20260630',
});
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630');
expect(result.icsString).not.toContain('T235959Z');
});
// D-06: RRULE UNTIL DATETIME UTC form — timed event
it('serializes UNTIL as DATETIME UTC form for timed events (D-06)', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z');
});
it('uses the provided uid when given', () => {
const uid = 'custom-uid-001@familysync';
const result = buildVeventString({
uid,
summary: 'Test event',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
});
expect(result.uid).toBe(uid);
expect(result.icsString).toContain(`UID:${uid}`);
});
it('generates a uid when none is provided', () => {
const result = buildVeventString({
summary: 'Auto uid event',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
});
expect(result.uid).toBeTruthy();
expect(result.uid.length).toBeGreaterThan(10);
});
});
describe('buildVeventString — D-13 form-parsed contract', () => {
it('timed event: produces BEGIN:VCALENDAR, SUMMARY, UID, timed DTSTART (Z suffix), and DTEND', () => {
// Simulates the field shape parsed by the worker from the stored form JSON
const result = buildVeventString({
uid: 'u1@familysync',
summary: 'Lunch',
allDay: false,
dtstart: new Date('2026-06-10T12:00:00Z'),
dtend: new Date('2026-06-10T13:00:00Z'),
});
expect(result.icsString).toContain('BEGIN:VCALENDAR');
expect(result.icsString).toContain('SUMMARY:Lunch');
expect(result.icsString).toContain('UID:u1@familysync');
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
// DTEND must be present
expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/);
});
it('single-day all-day event: DTSTART is DATE format and DTEND = DTSTART + 1 day (RFC-5545 exclusive end, WR-04)', () => {
// Simulates a single-day all-day event where start and end are the same calendar day.
// WR-04: the outbox worker passes the user-entered inclusive end; vevent.ts must advance it.
const result = buildVeventString({
summary: 'Birthday',
allDay: true,
dtstart: '2026-06-10',
dtend: '2026-06-10',
});
// DTSTART must be DATE format (no time component, no TZID) — D-13 all-day contract
expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/);
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/);
// WR-04: DTEND must be DTSTART + 1 day (RFC-5545 exclusive end)
expect(result.icsString).toMatch(/DTEND[^:]*:20260611/);
// DTEND date string must NOT equal DTSTART date string (owning-boundary assertion)
const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/);
const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/);
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]);
});
});
// ─── Phase 11 Plan 01: VALARM builders (CAL-13) ──────────────────────────────
describe('buildTimedValarm', () => {
it('returns a VALARM component with TRIGGER:-PT30M and no VALUE=TEXT', () => {
const valarm = buildTimedValarm(30);
const ics = valarm.toString();
expect(ics).toContain('TRIGGER:-PT30M');
expect(ics).not.toContain('VALUE=TEXT');
expect(ics).toContain('ACTION:DISPLAY');
expect(ics).toContain('DESCRIPTION:Reminder');
});
it('returns a VALARM with TRIGGER:-PT2H (or -PT120M) for 120 minutes and no VALUE=TEXT', () => {
const valarm = buildTimedValarm(120);
const ics = valarm.toString();
// ical.js may emit -PT2H or -PT120M — either is RFC-valid
expect(ics).toMatch(/TRIGGER:-P(?:T2H|T120M)/);
expect(ics).not.toContain('VALUE=TEXT');
});
});
describe('buildAllDayValarm', () => {
it('returns a VALARM with absolute DATE-TIME trigger in UTC (Z suffix, no DURATION)', () => {
const alertInstant = new Date('2026-06-14T13:00:00Z');
const valarm = buildAllDayValarm(alertInstant);
const ics = valarm.toString();
// Must contain the absolute UTC datetime
expect(ics).toContain('20260614T130000Z');
// Must carry VALUE=DATE-TIME
expect(ics).toContain('VALUE=DATE-TIME');
// Must NOT be a DURATION trigger
expect(ics).not.toMatch(/TRIGGER:-PT/);
expect(ics).toContain('ACTION:DISPLAY');
expect(ics).toContain('DESCRIPTION:Reminder');
});
});
describe('buildVeventString — VALARM emission (CAL-13)', () => {
it('emits exactly one VALARM with TRIGGER:-PT15M for a timed event with reminderLeadMinutes=15', () => {
const result = buildVeventString({
summary: 'Meeting',
allDay: false,
dtstart: new Date('2026-06-15T14:00:00Z'),
dtend: new Date('2026-06-15T15:00:00Z'),
reminderLeadMinutes: 15,
});
expect(result.icsString).toContain('BEGIN:VALARM');
expect(result.icsString).toContain('TRIGGER:-PT15M');
expect(result.icsString).not.toContain('VALUE=TEXT');
// Only one VALARM block
const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length;
expect(count).toBe(1);
});
it('emits NO VALARM for a timed event with reminderLeadMinutes=0 (D-06: 0 on timed = None)', () => {
const result = buildVeventString({
summary: 'Meeting',
allDay: false,
dtstart: new Date('2026-06-15T14:00:00Z'),
dtend: new Date('2026-06-15T15:00:00Z'),
reminderLeadMinutes: 0,
});
expect(result.icsString).not.toContain('BEGIN:VALARM');
});
it('emits one VALARM with absolute DATE-TIME trigger for an all-day event with reminderLeadMinutes=1440', () => {
const alertInstant = new Date('2026-06-14T13:00:00Z');
const result = buildVeventString({
summary: 'Anniversary',
allDay: true,
dtstart: '2026-06-15',
dtend: '2026-06-15',
reminderLeadMinutes: 1440,
allDayAlertInstantUtc: alertInstant,
});
expect(result.icsString).toContain('BEGIN:VALARM');
expect(result.icsString).toContain('VALUE=DATE-TIME');
expect(result.icsString).toContain('20260614T130000Z');
expect(result.icsString).not.toContain('VALUE=TEXT');
});
it('emits NO VALARM when reminderLeadMinutes is null', () => {
const result = buildVeventString({
summary: 'No reminder event',
allDay: false,
dtstart: new Date('2026-06-15T14:00:00Z'),
dtend: new Date('2026-06-15T15:00:00Z'),
reminderLeadMinutes: null,
});
expect(result.icsString).not.toContain('BEGIN:VALARM');
});
it('emits the supplied VALARM verbatim via params.valarms (preserve path) even when reminderLeadMinutes is also set', () => {
const preservedValarm = buildTimedValarm(60);
const result = buildVeventString({
summary: 'Preserved alarm',
allDay: false,
dtstart: new Date('2026-06-15T14:00:00Z'),
dtend: new Date('2026-06-15T15:00:00Z'),
valarms: [preservedValarm],
reminderLeadMinutes: 15, // should be ignored — preserve wins
});
// The preserved 60-min alarm is present
expect(result.icsString).toContain('TRIGGER:-PT1H');
// The 15-min alarm is NOT additionally synthesized
expect(result.icsString).not.toContain('TRIGGER:-PT15M');
// Exactly one VALARM block
const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length;
expect(count).toBe(1);
});
});
// ─── Phase 11 Plan 01: VALARM classifier + extractor (CAL-14) ────────────────
// Minimal ICS shell wrapping a VEVENT — used in classifier tests
function makeIcs(veventBody: string): string {
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Test//Test//EN',
'BEGIN:VEVENT',
'UID:test@test',
'SUMMARY:Test',
'DTSTART:20260615T140000Z',
'DTEND:20260615T150000Z',
veventBody,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
}
describe('classifyValarms', () => {
it('returns { kind: "none" } for an ICS with no VALARM', () => {
expect(classifyValarms(makeIcs(''))).toEqual({ kind: 'none' });
});
it('returns { kind: "preset", leadMinutes: 15 } for a single TRIGGER:-PT15M', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'preset', leadMinutes: 15 });
});
it('returns { kind: "offlist", leadMinutes: 45 } for a single TRIGGER:-PT45M', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT45M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'offlist', leadMinutes: 45 });
});
it('returns { kind: "custom" } for an absolute DATE-TIME trigger', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER;VALUE=DATE-TIME:20260615T130000Z\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
});
it('returns { kind: "custom" } for two VALARM blocks', () => {
const ics = makeIcs(
[
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
'DESCRIPTION:Reminder',
'END:VALARM',
'BEGIN:VALARM',
'TRIGGER:-PT30M',
'ACTION:DISPLAY',
'DESCRIPTION:Reminder',
'END:VALARM',
].join('\r\n'),
);
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
});
it('returns { kind: "none" } for unparseable input (safe default)', () => {
expect(classifyValarms('not valid ics')).toEqual({ kind: 'none' });
});
it('classifies 1440 (1 day) as preset', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-P1D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
const result = classifyValarms(ics);
expect(result.kind).toBe('preset');
if (result.kind === 'preset') expect(result.leadMinutes).toBe(1440);
});
it('classifies 10080 (1 week) as preset', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-P7D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
const result = classifyValarms(ics);
expect(result.kind).toBe('preset');
if (result.kind === 'preset') expect(result.leadMinutes).toBe(10080);
});
});
describe('extractValarms', () => {
it('returns an array of length 1 for an ICS with one VALARM', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT30M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
const result = extractValarms(ics);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(ICAL.Component);
});
it('returns an empty array for an ICS with no VALARM', () => {
expect(extractValarms(makeIcs(''))).toHaveLength(0);
});
it('returns an empty array for garbage input', () => {
expect(extractValarms('garbage ics')).toHaveLength(0);
});
it('returns a live ICAL.Component re-attachable via addSubcomponent (round-trip)', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT60M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
const [alarm] = extractValarms(ics);
// Should be usable as a component — attach it to a new vevent
const vevent = new ICAL.Component('vevent');
expect(() => vevent.addSubcomponent(alarm)).not.toThrow();
// ical.js may normalize -PT60M to -PT1H or keep -PT60M — both are RFC-valid
const serialized = vevent.toString();
expect(serialized).toMatch(/TRIGGER:-P(?:T60M|T1H)/);
});
});
// ─── Phase 11 Plan 01: computeAlertInstantUtc (NOTIF-06) ─────────────────────
describe('computeAlertInstantUtc', () => {
it('same day (leadDays=0), America/New_York summer → 2026-06-15T13:00:00.000Z (9 AM EDT = UTC-4)', () => {
const result = computeAlertInstantUtc('2026-06-15', 0, 'America/New_York');
expect(result.toISOString()).toBe('2026-06-15T13:00:00.000Z');
});
it('1 day before (leadDays=1), America/New_York summer → 2026-06-14T13:00:00.000Z', () => {
const result = computeAlertInstantUtc('2026-06-15', 1, 'America/New_York');
expect(result.toISOString()).toBe('2026-06-14T13:00:00.000Z');
});
it('winter (leadDays=0), America/New_York → 2026-01-15T14:00:00.000Z (9 AM EST = UTC-5)', () => {
const result = computeAlertInstantUtc('2026-01-15', 0, 'America/New_York');
expect(result.toISOString()).toBe('2026-01-15T14:00:00.000Z');
});
it('spring-forward DST (US 2026-03-08) — 9 AM local uses the post-transition offset (-4 → UTC+13:00:00Z)', () => {
// 2026-03-08 is the spring-forward day in the US; after 2 AM clocks move +1h
// 9 AM on 2026-03-08 in America/New_York = 13:00 UTC (EDT, UTC-4)
const result = computeAlertInstantUtc('2026-03-08', 0, 'America/New_York');
expect(result.toISOString()).toBe('2026-03-08T13:00:00.000Z');
});
it('fall-back DST (US 2026-11-01) — 9 AM local uses the post-transition offset (-5 → UTC+14:00:00Z)', () => {
// 2026-11-01 is the fall-back day; clocks move back 1h at 2 AM
// 9 AM on 2026-11-01 in America/New_York = 14:00 UTC (EST, UTC-5)
const result = computeAlertInstantUtc('2026-11-01', 0, 'America/New_York');
expect(result.toISOString()).toBe('2026-11-01T14:00:00.000Z');
});
});
// ─── Phase 11 Plan 05 WR-01: positive-duration TRIGGER classifies as custom ──
describe('classifyValarms — WR-01: positive-duration trigger (fires after event)', () => {
// RFC 5545 allows TRIGGER:+PT15M — fires 15 min AFTER event start.
// Before the fix: Math.abs() discards the sign and classifies it as preset/offlist 15.
// After the fix: positive duration → {kind:'custom'} (preserve as-is).
it('WR-01: TRIGGER:+PT15M classifies as custom, NOT preset', () => {
// TRIGGER:+PT15M is a post-event alarm; must not be mis-read as a 15-min-before lead.
// This test MUST FAIL before the fix.
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER;RELATED=END:PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
});
it('WR-01: TRIGGER:+PT30M classifies as custom, NOT offlist', () => {
// 30 min is in PRESET_MINUTES — without the sign check, Math.abs would give preset/30.
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:PT30M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
});
it('WR-01: negative TRIGGER:-PT15M still classifies as preset (unchanged behavior)', () => {
// Regression guard: negative triggers must not be affected by the fix.
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'preset', leadMinutes: 15 });
});
it('WR-01: negative off-list TRIGGER:-PT45M still classifies as offlist', () => {
const ics = makeIcs(
'BEGIN:VALARM\r\nTRIGGER:-PT45M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
);
expect(classifyValarms(ics)).toEqual({ kind: 'offlist', leadMinutes: 45 });
});
});