/** * 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(); expect(vevent.toString()).toContain('TRIGGER:-PT1H'); }); }); // ─── 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'); }); });