'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const dbPath = path.join(os.tmpdir(), `bill-tracker-calendar-feed-test-${process.pid}.sqlite`); process.env.DB_PATH = dbPath; const { buildCalendarFeed, createToken, escapeText, foldLine, rruleForCycle, } = require('../services/calendarFeedService'); const { getDb, closeDb } = require('../db/database'); function createUser(db, username = 'calendar-user') { return db.prepare(` INSERT INTO users (username, password_hash, role, active, created_at, updated_at) VALUES (?, 'x', 'user', 1, datetime('now'), datetime('now')) `).run(username).lastInsertRowid; } function createBill(db, userId, overrides = {}) { return db.prepare(` INSERT INTO bills ( user_id, name, due_day, expected_amount, cycle_type, cycle_day, billing_cycle, active, autopay_enabled, autodraft_status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, 'none', datetime('now'), datetime('now')) `).run( userId, overrides.name || 'Water, Power; Internet', overrides.due_day || 15, overrides.expected_amount || 12345, overrides.cycle_type || 'monthly', overrides.cycle_day || '1', overrides.billing_cycle || 'monthly', overrides.autopay_enabled || 0, ).lastInsertRowid; } function callFeedRoute(query) { const router = require('../routes/calendarFeed'); const layer = router.stack.find(item => item.route?.path === '/feed.ics' && item.route.methods.get); assert.ok(layer, 'GET /feed.ics route should exist'); const handler = layer.route.stack[0].handle; return new Promise((resolve, reject) => { const req = { query, }; const headers = {}; const res = { statusCode: 200, body: '', headers, status(code) { this.statusCode = code; return this; }, type(value) { headers['Content-Type'] = value; return this; }, set(values) { Object.assign(headers, values); return this; }, send(body) { this.body = body; resolve({ status: this.statusCode, headers, body }); }, }; try { handler(req, res); } catch (err) { reject(err); } }); } test.after(() => { closeDb(); for (const suffix of ['', '-wal', '-shm']) { fs.rmSync(`${dbPath}${suffix}`, { force: true }); } }); test('escapeText follows RFC 5545 TEXT escaping', () => { assert.equal( escapeText('Comma, semicolon; slash\\ newline\nnext'), 'Comma\\, semicolon\\; slash\\\\ newline\\nnext', ); }); test('foldLine folds at 75 octets without splitting UTF-8 characters', () => { const folded = foldLine(`SUMMARY:${'Long title '.repeat(12)}😀`); const physicalLines = folded.split('\r\n'); assert.ok(physicalLines.length > 1); for (const line of physicalLines) { assert.ok(Buffer.byteLength(line, 'utf8') <= 75); } assert.ok(physicalLines.slice(1).every(line => line.startsWith(' '))); assert.equal(folded.replace(/\r\n /g, ''), `SUMMARY:${'Long title '.repeat(12)}😀`); }); test('rruleForCycle maps all five bill cycle types', () => { assert.equal(rruleForCycle({ cycle_type: 'monthly', due_day: 12 }), 'FREQ=MONTHLY;BYMONTHDAY=12'); assert.equal(rruleForCycle({ cycle_type: 'weekly', cycle_day: 'friday', due_day: 12 }), 'FREQ=WEEKLY;BYDAY=FR'); assert.equal(rruleForCycle({ cycle_type: 'biweekly', cycle_day: 'monday', due_day: 12 }), 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO'); assert.equal(rruleForCycle({ cycle_type: 'quarterly', cycle_day: '2', due_day: 28 }), 'FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=28'); assert.equal(rruleForCycle({ cycle_type: 'annual', cycle_day: '11', due_day: 30 }), 'FREQ=YEARLY;BYMONTH=11;BYMONTHDAY=30'); }); test('buildCalendarFeed emits CRLF ICS without BOM or VTIMEZONE', () => { const db = getDb(); const userId = createUser(db, 'feed-build'); createBill(db, userId, { name: 'Rent' }); const { ics, events } = buildCalendarFeed(userId, { now: new Date(Date.UTC(2026, 5, 7)), pastMonths: 0, futureMonths: 1, dtstamp: '20260607T120000Z', }, db); assert.ok(events.length >= 1); assert.equal(ics.charCodeAt(0), 'B'.charCodeAt(0)); assert.ok(ics.includes('\r\n')); assert.ok(!ics.includes('\nBEGIN:VEVENT') || ics.includes('\r\nBEGIN:VEVENT')); assert.ok(!ics.includes('BEGIN:VTIMEZONE')); assert.match(ics, /DTSTART;VALUE=DATE:\d{8}\r\n/); assert.match(ics, /DTEND;VALUE=DATE:\d{8}\r\n/); assert.match(ics, /UID:bill-tracker-bill-\d+-\d{4}-\d{2}-\d{2}@bill-tracker\r\n/); }); test('public feed endpoint serves ICS for valid token and rejects invalid tokens', async () => { const db = getDb(); const userId = createUser(db, 'feed-http'); createBill(db, userId, { name: 'Phone Plan', due_day: 8 }); const token = createToken(userId, 'Test Feed', db); const missing = await callFeedRoute({ token: 'missing' }); assert.equal(missing.status, 404); const response = await callFeedRoute({ token: token.token }); assert.equal(response.status, 200); assert.equal(response.headers['Content-Type'], 'text/calendar; charset=utf-8'); assert.ok(response.body.includes('BEGIN:VCALENDAR\r\n')); assert.ok(response.body.includes('SUMMARY:Phone Plan due\r\n')); const updated = db.prepare('SELECT last_used_at FROM calendar_tokens WHERE id = ?').get(token.id); assert.ok(updated.last_used_at); });