BillTracker/tests/calendarFeedService.test.js

161 lines
5.4 KiB
JavaScript

'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);
});