import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { ProviderStore, assertValidKey, assertValidSpec } from '../src/providerStore.js'; function seed(initial) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'store-')); const file = path.join(dir, 'feeds.json'); fs.writeFileSync(file, JSON.stringify(initial ?? { providers: {} })); return file; } test('load + listByRoom returns only providers for that room', () => { const file = seed({ providers: { a: { name: 'A', roomId: 'r1', rss: { url: 'https://a.example/rss' } }, b: { name: 'B', roomId: 'r2', webhook: { format: 'statusPage' } }, c: { name: 'C', roomId: 'r1', webhook: { format: 'uptimeKuma' } }, }, }); const store = ProviderStore.load(file); const inR1 = store.listByRoom('r1').map(p => p.key).sort(); assert.deepEqual(inR1, ['a', 'c']); assert.deepEqual(store.listByRoom('r2').map(p => p.key), ['b']); assert.deepEqual(store.listByRoom('missing'), []); }); test('upsert adds a new provider and persists to disk', async () => { const file = seed(); const store = ProviderStore.load(file); await store.upsert('acme', { name: 'Acme', roomId: 'room-1', rss: { url: 'https://acme.example/rss' }, }); const reloaded = JSON.parse(fs.readFileSync(file, 'utf8')); assert.equal(reloaded.providers.acme.name, 'Acme'); assert.equal(reloaded.providers.acme.rss.url, 'https://acme.example/rss'); }); test('upsert preserves rss.lastCheck on edit', async () => { const stamp = '2026-01-01T00:00:00.000Z'; const file = seed({ providers: { acme: { name: 'Acme', roomId: 'r', rss: { url: 'https://a/rss', lastCheck: stamp } }, }, }); const store = ProviderStore.load(file); await store.upsert('acme', { name: 'Acme (renamed)', roomId: 'r', rss: { url: 'https://a/rss2' }, }); assert.equal(store.get('acme').rss.lastCheck, stamp); assert.equal(store.get('acme').rss.url, 'https://a/rss2'); assert.equal(store.get('acme').name, 'Acme (renamed)'); }); test('remove deletes and persists', async () => { const file = seed({ providers: { x: { name: 'X', roomId: 'r', rss: { url: 'https://x/rss' } } } }); const store = ProviderStore.load(file); assert.equal(await store.remove('x'), true); assert.equal(store.get('x'), undefined); const reloaded = JSON.parse(fs.readFileSync(file, 'utf8')); assert.equal(reloaded.providers.x, undefined); assert.equal(await store.remove('x'), false); }); test('reload picks up out-of-band edits without breaking existing refs', () => { const file = seed({ providers: { a: { name: 'A', roomId: 'r', webhook: { format: 'statusPage' } } } }); const store = ProviderStore.load(file); const rawRef = store.raw; fs.writeFileSync(file, JSON.stringify({ providers: { b: { name: 'B', roomId: 'r', webhook: { format: 'statusIO' } } }, })); store.reload(); assert.equal(store.get('a'), undefined); assert.equal(store.get('b').name, 'B'); // The reference the caller took earlier must still point at live data. assert.equal(rawRef.providers.b.name, 'B'); }); test('assertValidKey rejects garbage', () => { assert.throws(() => assertValidKey(''), /Invalid provider key/); assert.throws(() => assertValidKey('has spaces'), /Invalid provider key/); assert.throws(() => assertValidKey('..'), /Invalid provider key/); assert.doesNotThrow(() => assertValidKey('valid-key_1.a')); }); test('assertValidSpec enforces required fields and formats', () => { assert.throws(() => assertValidSpec({}), /name is required/); assert.throws(() => assertValidSpec({ name: 'x' }), /roomId is required/); assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r' }), /at least one of rss or webhook/); assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r', rss: { url: 'not-a-url' } }), /http\(s\) URL/); assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r', webhook: { format: 'nope' } }), /webhook\.format must be one of/); assert.doesNotThrow(() => assertValidSpec({ name: 'x', roomId: 'r', rss: { url: 'https://a/rss' } })); assert.doesNotThrow(() => assertValidSpec({ name: 'x', roomId: 'r', webhook: { format: 'statusPage' } })); });