import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { generateIcal, generateIcalFromQueue } from '../ical-generator.mjs'; const SAMPLE_EVENT = { id: 'post-2026-04-14-ai-strategy', title: 'LinkedIn: AI strategy in public sector', description: 'Pillar: AI Strategy | Format: Standard | Draft: assets/drafts/week-W16/monday.md', date: '2026-04-14', time: '08:30', duration: 30, }; const SAMPLE_QUEUE_ENTRY = { id: 'post-2026-04-14-ai-strategy', draft_path: 'assets/drafts/week-W16/monday.md', scheduled_date: '2026-04-14', scheduled_time: '08:30', pillar: 'AI Strategy', format: 'Standard', hook_preview: 'AI strategy in public sector', character_count: 1450, status: 'scheduled', created_at: '2026-04-10', }; describe('generateIcal', () => { test('returns valid empty VCALENDAR for empty events array', () => { const ical = generateIcal([]); assert.match(ical, /^BEGIN:VCALENDAR\r\n/); assert.match(ical, /\r\nEND:VCALENDAR\r\n$/); assert.match(ical, /PRODID:-\/\/linkedin-studio\/\/EN/); assert.match(ical, /VERSION:2\.0/); assert.ok(!ical.includes('BEGIN:VEVENT'), 'should not contain VEVENT'); }); test('generates VEVENT with correct DTSTART, SUMMARY, UID', () => { const ical = generateIcal([SAMPLE_EVENT]); assert.match(ical, /BEGIN:VEVENT/); assert.match(ical, /DTSTART;TZID=Europe\/Oslo:20260414T083000/); assert.match(ical, /SUMMARY:LinkedIn: AI strategy in public sector/); assert.match(ical, /UID:post-2026-04-14-ai-strategy@linkedin-studio/); assert.match(ical, /END:VEVENT/); }); test('generates correct DTEND from duration', () => { const ical = generateIcal([SAMPLE_EVENT]); assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/); }); test('defaults duration to 30 minutes when not specified', () => { const event = { ...SAMPLE_EVENT, duration: undefined }; const ical = generateIcal([event]); assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/); }); test('has CRLF line endings throughout', () => { const ical = generateIcal([SAMPLE_EVENT]); const lines = ical.split('\r\n'); assert.ok(lines.length > 5, 'should have multiple lines'); const bareLF = ical.replace(/\r\n/g, '').includes('\n'); assert.ok(!bareLF, 'should not contain bare LF without CR'); }); test('includes VALARM with 15-minute trigger', () => { const ical = generateIcal([SAMPLE_EVENT]); assert.match(ical, /BEGIN:VALARM/); assert.match(ical, /TRIGGER:-PT15M/); assert.match(ical, /ACTION:DISPLAY/); assert.match(ical, /END:VALARM/); }); test('includes DTSTAMP in UTC format', () => { const ical = generateIcal([SAMPLE_EVENT]); assert.match(ical, /DTSTAMP:\d{8}T\d{6}Z/); }); test('folds lines longer than 75 octets', () => { const longDescription = 'A'.repeat(200); const event = { ...SAMPLE_EVENT, description: longDescription }; const ical = generateIcal([event]); const lines = ical.split('\r\n'); for (const line of lines) { const octets = Buffer.byteLength(line, 'utf-8'); assert.ok(octets <= 75, `Line exceeds 75 octets (${octets}): "${line.slice(0, 40)}..."`); } }); test('escapes special characters in SUMMARY and DESCRIPTION', () => { const event = { ...SAMPLE_EVENT, title: 'Test: commas, semicolons; and\\backslashes', description: 'Line1\nLine2, with; special\\chars', }; const ical = generateIcal([event]); assert.match(ical, /SUMMARY:Test: commas\\, semicolons\; and\\\\backslashes/); assert.match(ical, /DESCRIPTION:Line1\\nLine2\\, with\; special\\\\chars/); }); test('handles multiple events', () => { const event2 = { ...SAMPLE_EVENT, id: 'post-2026-04-16-leadership', title: 'LinkedIn: Leadership lessons', date: '2026-04-16', time: '12:00', }; const ical = generateIcal([SAMPLE_EVENT, event2]); const veventCount = (ical.match(/BEGIN:VEVENT/g) || []).length; assert.equal(veventCount, 2); }); test('includes VTIMEZONE for Europe/Oslo', () => { const ical = generateIcal([SAMPLE_EVENT]); assert.match(ical, /BEGIN:VTIMEZONE/); assert.match(ical, /TZID:Europe\/Oslo/); assert.match(ical, /END:VTIMEZONE/); }); test('supports custom timezone parameter', () => { const ical = generateIcal([SAMPLE_EVENT], { timezone: 'America/New_York' }); assert.match(ical, /TZID:America\/New_York/); assert.match(ical, /DTSTART;TZID=America\/New_York/); }); }); describe('generateIcalFromQueue', () => { test('transforms queue entry format to event format', () => { const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]); assert.equal(events.length, 1); const e = events[0]; assert.equal(e.id, 'post-2026-04-14-ai-strategy'); assert.equal(e.date, '2026-04-14'); assert.equal(e.time, '08:30'); assert.ok(e.title.includes('AI strategy in public sector')); assert.ok(e.description.includes('AI Strategy')); assert.ok(e.description.includes('Standard')); }); test('handles missing scheduled_time gracefully', () => { const entry = { ...SAMPLE_QUEUE_ENTRY, scheduled_time: undefined }; const events = generateIcalFromQueue([entry]); assert.equal(events[0].time, '09:00'); }); test('handles empty array', () => { const events = generateIcalFromQueue([]); assert.deepEqual(events, []); }); test('generates valid iCal when piped through generateIcal', () => { const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]); const ical = generateIcal(events); assert.match(ical, /BEGIN:VCALENDAR/); assert.match(ical, /BEGIN:VEVENT/); assert.match(ical, /END:VCALENDAR/); }); });