'use strict'; /** * Server-entry tests for Resi Cues. * * node modules/resi-cues/server.test.js * * Plain Node, no dependencies; exits non-zero on the first failure. Resi is * replaced with a canned account — a venue, a decoder on an event, four cue * points — so this covers the parts `resi.test.js` cannot: signing in, finding * the decoder, following the event, and the two snapshots other things read * (`/state` for the tile, `/timers` for the Timers module). * * Nothing here reaches the network, and the poll interval is captured rather * than run, so the test drives the loop a tick at a time. */ const assert = require('assert'); let passed = 0; // Awaited, so an async test finishes before the next one changes the world under it. async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); } /* ── a canned Resi account ─────────────────────────────────────────────── */ const CUES = [ { name: 'Welcome', position: '00:05:00.000' }, { name: 'Campus Host', position: '00:12:30.000' }, { name: 'Message', position: '00:41:00.000' }, ]; const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 }; function reply(body, extra) { return Object.assign({ ok: true, status: 200, headers: { get: () => null, getSetCookie: () => [] }, json: async () => body, }, extra || {}); } globalThis.fetch = async (url) => { const path = String(url).replace('https://central.resi.io', '').split('?')[0]; if (path === '/api/v3/login') { world.logins++; return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } }); } if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' }); if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]); if (path === '/api_v2.svc/users/V1/players') { world.playerCalls++; return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]); } if (path === '/api/v3/customers/CUST/events/EV1') { return reply({ name: 'Sunday 9am', eventProfileId: 'P1' }); } if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES); return reply({}, { ok: false, status: 404 }); }; /* ── the shell, as much of it as the module touches ────────────────────── */ const realSetInterval = globalThis.setInterval; const realClearInterval = globalThis.clearInterval; let tickFn = null; globalThis.setInterval = (fn, ms) => { if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one return { fn, ms }; }; globalThis.clearInterval = () => {}; const server = require('./server'); const logged = []; const handle = server.init({ config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 }, log: (m) => logged.push(m), }); /** Let the poll's promise chain finish; nothing here waits on real time. */ async function settle() { for (let i = 0; i < 60; i++) await Promise.resolve(); } function collect(route, req) { const res = { code: 0, headers: null, body: '', writeHead(code, headers) { this.code = code; this.headers = headers; }, write(chunk) { this.body += chunk; }, end(chunk) { if (chunk) this.body += chunk; }, }; route(req || { on() {} }, res); return res; } (async () => { await settle(); const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); await test('signs in once and finds the configured decoder', () => { assert.strictEqual(world.logins, 1, 'should sign in exactly once'); const state = handle.state; assert.strictEqual(state.status, 'ok', state.message); assert.strictEqual(state.decoder, 'Main — Lobby'); assert.strictEqual(state.event, 'Sunday 9am'); assert.strictEqual(state.cues.length, 3); }); await test('works out the cue either side of the playhead', () => { const state = handle.state; assert.strictEqual(state.next.name, 'Campus Host'); assert.strictEqual(state.prev.name, 'Welcome'); assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); }); await test('/state answers the same snapshot as the stream', () => { const res = collect(routes['GET /state']); assert.strictEqual(res.code, 200); assert.deepStrictEqual(JSON.parse(res.body), handle.state); }); await test('/stream opens with an event so a new tile paints at once', () => { const res = collect(routes['GET /stream']); assert.strictEqual(res.code, 200); assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); assert.ok(res.body.includes('retry: 3000'), 'should set a retry'); assert.ok(res.body.includes('event: state'), 'should send an initial state event'); }); await test('/timers offers the countdown and the playhead, shaped as the guide says', () => { const snap = JSON.parse(collect(routes['GET /timers']).body); assert.strictEqual(snap.source, 'resi-cues'); assert.strictEqual(snap.label, 'Resi'); assert.ok(snap.updatedAt > 0); const [next, playhead] = snap.timers; assert.strictEqual(next.id, 'next-cue'); assert.strictEqual(next.kind, 'countdown'); assert.strictEqual(next.state, 'running'); assert.strictEqual(next.label, 'Campus Host'); assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); // the countdown runs from the cue before it, so a consumer can colour progress assert.strictEqual(next.targetMs, 450000); assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); assert.strictEqual(playhead.id, 'playhead'); assert.strictEqual(playhead.kind, 'elapsed'); assert.strictEqual(playhead.remainingMs, null); assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); }); await test('a cue fired at the host moves the countdown on the next poll', async () => { world.position = '00:12:40.000'; // jumped past Campus Host tickFn(); await settle(); const state = handle.state; assert.strictEqual(state.next.name, 'Message'); assert.strictEqual(state.prev.name, 'Campus Host'); const snap = JSON.parse(collect(routes['GET /timers']).body); assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue }); await test('a decoder with no event reports nothing playing rather than stale cues', async () => { world.eventId = ''; tickFn(); await settle(); assert.strictEqual(handle.state.next, null); assert.deepStrictEqual(handle.state.cues, []); assert.strictEqual(handle.state.event, ''); }); await test('health speaks about the upstream, not about itself', () => { const h = handle.health(); assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); assert.ok(h.message.length > 0, 'health should say something useful'); }); await test('an unconfigured module idles instead of hammering Resi', () => { const before = world.logins; const idle = server.init({ config: {}, log: () => {} }); assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); assert.strictEqual(idle.health().status, 'error'); assert.ok(/login/i.test(idle.health().message), idle.health().message); idle.stop(); }); await test('stop() leaves nothing running', () => { handle.stop(); const res = collect(routes['GET /state']); assert.strictEqual(JSON.parse(res.body).status, 'error'); }); // Seen live at North Attleboro: a failed login was retried on every 1s poll, so // Resi answered 429 and kept answering it, and the decoder picker stayed empty. await test('a failed login holds off instead of retrying every poll', async () => { const { createResiClient } = require('./resi'); let t = 1700000000000; let logins = 0; let limited = true; const client = createResiClient({ username: 'a@b.c', password: 'secret', now: () => t, fetch: async (url) => { const path = String(url).replace('https://central.resi.io', '').split('?')[0]; if (path === '/api/v3/login') { logins++; if (limited) return reply({}, { ok: false, status: 429 }); return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } }); } if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' }); if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]); return reply({}, { ok: false, status: 404 }); }, }); const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } }; assert.match(await poll(), /rate-limiting/); for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`); assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden'); limited = false; t += 5 * 60000; // past the longest hold-off assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in'); const after = logins; for (let i = 0; i < 30; i++) { t += 1000; await poll(); } assert.strictEqual(logins, after, 'a good session is not signed in again'); }); await test('a rejected password says so', async () => { const { createResiClient } = require('./resi'); const client = createResiClient({ username: 'a@b.c', password: 'wrong', now: () => 0, fetch: async () => reply({}, { ok: false, status: 400 }), }); await assert.rejects(client.venues(), /check the password/); }); // Seen live: media@waters.church signs in fine but may not list venues (403). The // client answered every 403 by signing in again -- a fresh login on every poll. await test('a login that may not see venues is held off, not signed in every poll', async () => { const { createResiClient } = require('./resi'); let t = 1700000000000; let logins = 0; let venueCalls = 0; const client = createResiClient({ username: 'a@b.c', password: 'secret', now: () => t, fetch: async (url) => { const path = String(url).replace('https://central.resi.io', '').split('?')[0]; if (path === '/api/v3/login') { logins++; return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } }); } if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' }); if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); } return reply({}, { ok: false, status: 404 }); }, }); const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } }; assert.match(await poll(), /isn't allowed to see its venues \(403\)/); for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls // At most one renewal once the session is over a minute old (in case it really // had expired), then the hold-off keeps growing -- not one sign-in per poll. assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`); assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`); assert.match(await poll(), /ask a Resi admin/); }); globalThis.setInterval = realSetInterval; globalThis.clearInterval = realClearInterval; console.log(`\n${passed} tests passed`); })();