From 860c7419ac6b82c0f06a933b997352364bf4e264 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 21:59:45 -0400 Subject: [PATCH] =?UTF-8?q?test(11-01):=20RED=20=E2=80=94=20VALARM=20build?= =?UTF-8?q?ers,=20classifier,=20extractor,=20computeAlertInstantUtc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add failing tests for buildTimedValarm, buildAllDayValarm (no VALUE=TEXT) - Add failing tests for buildVeventString VALARM emission (timed/all-day/null/preserve) - Add failing tests for classifyValarms (none/preset/offlist/custom) - Add failing tests for extractValarms (round-trip, empty, garbage) - Add failing tests for computeAlertInstantUtc DST boundaries (spring/fall/summer/winter) - Import ICAL from ical.js in test file for Component instanceof checks --- apps/api/tests/broker/vevent.test.ts | 282 ++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 8 deletions(-) diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 568a04b..0ed35f8 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -1,5 +1,5 @@ /** - * RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07) + * 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 @@ -7,17 +7,22 @@ * 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) - * - * These tests FAIL (RED) because broker/vevent.ts does not exist yet. - * They will turn GREEN in Plan 03-02 when the implementation is added. + * 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'; -// This import fails (RED) — broker/vevent.ts does not exist yet. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore intentional RED import -import { buildVeventString } from '../../src/broker/vevent.js'; +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', () => { @@ -196,3 +201,264 @@ describe('buildVeventString — D-13 form-parsed contract', () => { 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'); + }); +});