Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept) - Notes on highlights and bookmarks: synced, merged without loss, in exports and backups - Reader verse panel lists your studies and chunks that cover the verse, and opens them - Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups and the account export; daily prayer reminder through ntfy - TODO.md lists the ideas not built yet Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
114 lines
6.0 KiB
JavaScript
114 lines
6.0 KiB
JavaScript
// @vitest-environment node
|
|
import http from 'node:http';
|
|
import { test, expect, afterEach } from 'vitest';
|
|
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
|
|
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
|
|
|
|
const at = (iso) => new Date(iso);
|
|
|
|
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
|
|
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
|
|
const d = at('2026-09-30T03:30:00Z');
|
|
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
|
|
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
|
|
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
|
|
});
|
|
|
|
test('timezone validation', () => {
|
|
expect(isValidTimeZone('America/Chicago')).toBe(true);
|
|
expect(isValidTimeZone('Mars/Base')).toBe(false);
|
|
expect(isValidTimeZone('')).toBe(false);
|
|
});
|
|
|
|
test('a reminder is due at its time in the user\'s timezone', () => {
|
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
|
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
|
|
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
|
|
});
|
|
|
|
test('a late server still sends today\'s reminder, but only within the grace window', () => {
|
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
|
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
|
|
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
|
|
});
|
|
|
|
test('it is not sent twice on the same local day', () => {
|
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
|
|
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
|
|
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
|
|
});
|
|
|
|
test('a missing or malformed time is never due', () => {
|
|
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
|
|
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
|
|
});
|
|
|
|
test('the message mentions how many plans are in progress', () => {
|
|
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
|
|
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
|
|
});
|
|
|
|
// --- delivery, against a local stand-in for an ntfy server ------------------------
|
|
let server;
|
|
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
|
|
|
|
const startNtfy = (handler) => new Promise((resolve) => {
|
|
const seen = [];
|
|
server = http.createServer((req, res) => {
|
|
let body = '';
|
|
req.on('data', (c) => { body += c; });
|
|
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
|
|
});
|
|
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
|
|
});
|
|
|
|
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
|
|
const ntfy = await startNtfy((res) => res.end('{}'));
|
|
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
|
|
expect(ntfy.seen).toHaveLength(1);
|
|
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
|
|
expect(ntfy.seen[0].headers.title).toBe('Hello');
|
|
});
|
|
|
|
test('the test notification reaches the topic', async () => {
|
|
const ntfy = await startNtfy((res) => res.end('{}'));
|
|
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
|
|
expect(ntfy.seen[0].url).toBe('/abc');
|
|
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
|
|
});
|
|
|
|
test('an ntfy error response becomes a readable failure', async () => {
|
|
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
|
|
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
|
|
});
|
|
|
|
test('an unreachable server says so instead of hanging', async () => {
|
|
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
|
|
});
|
|
|
|
test('private and loopback addresses are rejected as reminder servers', async () => {
|
|
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
|
|
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
|
|
expect(isPrivateAddress('8.8.8.8')).toBe(false);
|
|
const prev = process.env.ALLOW_PRIVATE_NTFY;
|
|
delete process.env.ALLOW_PRIVATE_NTFY;
|
|
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
|
|
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
|
|
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
|
|
process.env.ALLOW_PRIVATE_NTFY = prev;
|
|
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
|
|
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
|
|
});
|
|
|
|
test('the prayer reminder lists the first few open requests and counts the rest', async () => {
|
|
const { prayerMessage } = await import('./reminders.js');
|
|
const open = [
|
|
{ text: 'Healing for the whole family and a peaceful recovery after the surgery next week, with no complications at all', forWhom: 'Mum' },
|
|
{ text: 'Wisdom at work', forWhom: '' }, { text: 'Rain', forWhom: 'Farm' }, { text: 'Peace', forWhom: '' }, { text: 'Joy', forWhom: '' },
|
|
];
|
|
const m = prayerMessage(open);
|
|
expect(m.title).toBe('Time to pray: 5 requests');
|
|
expect(m.body.split('\n')).toEqual([`• Mum: ${open[0].text.slice(0, 80)}…`, '• Wisdom at work', '• Farm: Rain', '…and 2 more']);
|
|
expect(prayerMessage([{ text: 'One', forWhom: '' }]).title).toBe('Time to pray: 1 request');
|
|
});
|