161 lines
5.4 KiB
JavaScript
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 || 123.45,
|
||
|
|
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);
|
||
|
|
});
|