Bridges third-party status pages into Webex spaces via RSS polling and inbound webhooks (Statuspage / Status.io / Uptime Kuma / generic). Includes an interactive Webex bot (websocket transport) that lets space members register sources with an Adaptive Card instead of hand-editing config/feeds.json: help, add, list, webhook <key>, remove <key>. Ships with an atomic JSON store (per-file mutex, tmp+rename), parallel RSS polling, and unit tests via node:test. All secrets are sourced from environment variables (see .env.example); no credentials in the repo. Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
4.4 KiB
JavaScript
102 lines
4.4 KiB
JavaScript
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' } }));
|
|
});
|