calendar-ics-normalizer-unit.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tier 1 — UNIT: ICS normalizer and SourceCalendar defaults in isolation. |
| 3 | * Reference: docs/CALENDAR-EVENTS-V0-SPEC.md — Phase 1A |
| 4 | */ |
| 5 | import { describe, it } from 'node:test'; |
| 6 | import assert from 'node:assert/strict'; |
| 7 | import { |
| 8 | parseIcsToEvents, |
| 9 | unfoldIcsLines, |
| 10 | parsePropertyLine, |
| 11 | unescapeIcsText, |
| 12 | normalizeStatus, |
| 13 | normalizeTimezoneId, |
| 14 | zonedLocalToUtc, |
| 15 | addDuration, |
| 16 | } from '../lib/calendar/ics-normalizer.mjs'; |
| 17 | import { |
| 18 | SOURCE_CALENDAR_DEFAULTS, |
| 19 | buildSourceCalendarDefaults, |
| 20 | isAgentTierAllowed, |
| 21 | AGENT_CONTEXT_TIERS, |
| 22 | } from '../lib/calendar/source-calendar-defaults.mjs'; |
| 23 | import { redactEventForAgentTier } from '../lib/calendar/agent-context-tier.mjs'; |
| 24 | |
| 25 | describe('SOURCE_CALENDAR_DEFAULTS', () => { |
| 26 | it('defaults display on, agents off, tier 0', () => { |
| 27 | assert.equal(SOURCE_CALENDAR_DEFAULTS.enabled_for_display, true); |
| 28 | assert.equal(SOURCE_CALENDAR_DEFAULTS.enabled_for_agents, false); |
| 29 | assert.equal(SOURCE_CALENDAR_DEFAULTS.agent_context_tier_max, 0); |
| 30 | assert.equal(SOURCE_CALENDAR_DEFAULTS.enabled_for_sync, true); |
| 31 | }); |
| 32 | |
| 33 | it('buildSourceCalendarDefaults rejects invalid tier', () => { |
| 34 | assert.throws(() => buildSourceCalendarDefaults({ agent_context_tier_max: 9 }), /0–4/); |
| 35 | }); |
| 36 | |
| 37 | it('isAgentTierAllowed is false for tier > 0 when agents disabled', () => { |
| 38 | const cal = buildSourceCalendarDefaults(); |
| 39 | assert.equal(isAgentTierAllowed(cal, 0), true); |
| 40 | assert.equal(isAgentTierAllowed(cal, 1), false); |
| 41 | assert.equal(isAgentTierAllowed(cal, 2), false); |
| 42 | }); |
| 43 | }); |
| 44 | |
| 45 | describe('unfoldIcsLines', () => { |
| 46 | it('joins folded continuation lines per RFC 5545 (removes CRLF + single whitespace)', () => { |
| 47 | const lines = unfoldIcsLines('SUMMARY:Hello wo\r\n rld\r\nUID:x'); |
| 48 | assert.equal(lines[0], 'SUMMARY:Hello world'); |
| 49 | assert.equal(lines[1], 'UID:x'); |
| 50 | }); |
| 51 | }); |
| 52 | |
| 53 | describe('parsePropertyLine', () => { |
| 54 | it('parses name, params, and value', () => { |
| 55 | const p = parsePropertyLine('DTSTART;TZID=America/Los_Angeles;VALUE=DATE:20260618'); |
| 56 | assert.ok(p); |
| 57 | assert.equal(p.name, 'DTSTART'); |
| 58 | assert.equal(p.params.TZID, 'America/Los_Angeles'); |
| 59 | assert.equal(p.params.VALUE, 'DATE'); |
| 60 | assert.equal(p.value, '20260618'); |
| 61 | }); |
| 62 | }); |
| 63 | |
| 64 | describe('unescapeIcsText', () => { |
| 65 | it('unescapes ICS text sequences', () => { |
| 66 | assert.equal(unescapeIcsText('a\\,b\\;c\\n d\\\\e'), 'a,b;c\n d\\e'); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | describe('normalizeStatus', () => { |
| 71 | it('maps ICS status strings to v0 enum', () => { |
| 72 | assert.equal(normalizeStatus('CANCELLED'), 'cancelled'); |
| 73 | assert.equal(normalizeStatus('TENTATIVE'), 'tentative'); |
| 74 | assert.equal(normalizeStatus(undefined), 'confirmed'); |
| 75 | }); |
| 76 | }); |
| 77 | |
| 78 | describe('normalizeTimezoneId', () => { |
| 79 | it('accepts valid IANA ids and rejects unknown', () => { |
| 80 | assert.equal(normalizeTimezoneId('UTC'), 'UTC'); |
| 81 | assert.throws(() => normalizeTimezoneId('Not/A/Timezone'), /Unknown IANA/); |
| 82 | }); |
| 83 | }); |
| 84 | |
| 85 | describe('zonedLocalToUtc', () => { |
| 86 | it('converts Pacific wall time to UTC (PDT in June)', () => { |
| 87 | const utc = zonedLocalToUtc( |
| 88 | { year: 2026, month: 6, day: 18, hour: 9, minute: 0, second: 0 }, |
| 89 | 'America/Los_Angeles', |
| 90 | ); |
| 91 | assert.equal(utc.toISOString(), '2026-06-18T16:00:00.000Z'); |
| 92 | }); |
| 93 | }); |
| 94 | |
| 95 | describe('addDuration', () => { |
| 96 | it('adds ISO duration to start instant', () => { |
| 97 | const start = new Date('2026-06-23T10:00:00.000Z'); |
| 98 | const end = addDuration(start, 'PT45M'); |
| 99 | assert.equal(end.toISOString(), '2026-06-23T10:45:00.000Z'); |
| 100 | }); |
| 101 | }); |
| 102 | |
| 103 | describe('parseIcsToEvents — minimal inline ICS', () => { |
| 104 | const minimal = `BEGIN:VCALENDAR |
| 105 | BEGIN:VEVENT |
| 106 | UID:test@x |
| 107 | DTSTART:20260101T120000Z |
| 108 | DTEND:20260101T130000Z |
| 109 | SUMMARY:Hello |
| 110 | END:VEVENT |
| 111 | END:VCALENDAR`; |
| 112 | |
| 113 | it('returns one normalized event', () => { |
| 114 | const events = parseIcsToEvents(minimal); |
| 115 | assert.equal(events.length, 1); |
| 116 | assert.equal(events[0].external_uid, 'test@x'); |
| 117 | assert.equal(events[0].summary, 'Hello'); |
| 118 | assert.equal(events[0].busy, true); |
| 119 | assert.equal(events[0].status, 'confirmed'); |
| 120 | }); |
| 121 | |
| 122 | it('skips VEVENT without UID or DTSTART', () => { |
| 123 | const bad = `BEGIN:VCALENDAR |
| 124 | BEGIN:VEVENT |
| 125 | SUMMARY:No uid |
| 126 | END:VEVENT |
| 127 | END:VCALENDAR`; |
| 128 | assert.equal(parseIcsToEvents(bad).length, 0); |
| 129 | }); |
| 130 | }); |
| 131 | |
| 132 | describe('redactEventForAgentTier', () => { |
| 133 | const event = { |
| 134 | external_uid: 'x@y', |
| 135 | start: '2026-06-18T17:00:00.000Z', |
| 136 | end: '2026-06-18T17:30:00.000Z', |
| 137 | timezone: 'UTC', |
| 138 | summary: 'Secret title', |
| 139 | busy: true, |
| 140 | status: 'confirmed', |
| 141 | recurrence_rule: null, |
| 142 | }; |
| 143 | |
| 144 | it('tier 0 returns null', () => { |
| 145 | assert.equal(redactEventForAgentTier(event, 0), null); |
| 146 | }); |
| 147 | |
| 148 | it('tier 1 omits summary', () => { |
| 149 | const r = redactEventForAgentTier(event, 1); |
| 150 | assert.ok(r); |
| 151 | assert.equal(r.summary, undefined); |
| 152 | }); |
| 153 | |
| 154 | it('tier 2 includes summary', () => { |
| 155 | const r = redactEventForAgentTier(event, 2, { calendar_label: 'Work' }); |
| 156 | assert.ok(r); |
| 157 | assert.equal(r.summary, 'Secret title'); |
| 158 | assert.equal(r.calendar_label, 'Work'); |
| 159 | }); |
| 160 | }); |
| 161 | |
| 162 | describe('AGENT_CONTEXT_TIERS', () => { |
| 163 | it('includes tiers 0 through 4', () => { |
| 164 | assert.deepEqual([...AGENT_CONTEXT_TIERS], [0, 1, 2, 3, 4]); |
| 165 | }); |
| 166 | }); |
File History
1 commit
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago