200 lines
12 KiB
JavaScript
200 lines
12 KiB
JavaScript
// node --test relay/test/wake.test.mjs (no dependencies, no database: a fake sql, a fake clock, a fake sleep)
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { waitForChange, RateLimit, validStamp, makeHandler, HOLD_MS_MAX, HOLDS_MAX, recordSeen, seenList, pingState, PING_SILENT_S, validMachine } from '../lib/wake.mjs';
|
|
|
|
// a clock that only moves when something sleeps on it
|
|
function clock(start = 1_000_000) {
|
|
let t = start;
|
|
return { now: () => t, sleep: async ms => { t += ms; }, set: v => { t = v; } };
|
|
}
|
|
|
|
// the smallest Neon stand-in: one table relay_wake, newest row first, no table until the first CREATE
|
|
function fakeDb() {
|
|
const rows = [];
|
|
const seen = [];
|
|
let created = false;
|
|
let seenCreated = false;
|
|
let seq = 0;
|
|
const nowIso = () => '2026-10-07T13:00:00.000Z';
|
|
const sql = async (query, params = []) => {
|
|
if (/CREATE TABLE IF NOT EXISTS relay_wake /.test(query)) { created = true; return []; }
|
|
if (/CREATE TABLE IF NOT EXISTS relay_wake_seen/.test(query)) { seenCreated = true; return []; }
|
|
if (/INSERT INTO relay_wake_seen/.test(query)) {
|
|
const [machine, version, last_job] = params;
|
|
const hit = seen.find(r => r.machine === machine);
|
|
if (hit) { hit.last_seen = nowIso(); hit.version = version; if (last_job) hit.last_job = last_job; } else seen.push({ machine, last_seen: nowIso(), version, last_job });
|
|
return [];
|
|
}
|
|
if (/SELECT machine, last_seen, version, last_job FROM relay_wake_seen/.test(query)) { if (!seenCreated) throw new Error('relation "relay_wake_seen" does not exist'); return seen.map(r => ({ ...r })); }
|
|
if (!created) throw new Error('relation "relay_wake" does not exist');
|
|
if (/INSERT INTO relay_wake/.test(query)) {
|
|
const [stamp, meta] = params;
|
|
const hit = rows.find(r => r.stamp === stamp);
|
|
if (hit) { hit.at = '2026-10-05 12:00:30+00'; hit.meta = JSON.parse(meta); return [{ stamp, at: hit.at }]; }
|
|
const row = { id: ++seq, stamp, at: '2026-10-05 12:00:00+00', meta: JSON.parse(meta) };
|
|
rows.push(row);
|
|
return [{ stamp, at: row.at }];
|
|
}
|
|
if (/SELECT stamp, at, meta FROM relay_wake/.test(query)) { const r = rows[rows.length - 1]; return r ? [r] : []; }
|
|
throw new Error('unexpected query ' + query);
|
|
};
|
|
return { sql, rows, seen, set: (stamp, added = []) => { rows.push({ id: ++seq, stamp, at: '2026-10-05 12:00:00+00', meta: { added } }); created = true; } };
|
|
}
|
|
|
|
function res() {
|
|
const r = { status: null, headers: {}, body: null };
|
|
r.setHeader = (k, v) => { r.headers[k] = v; };
|
|
r.status = s => { r.code = s; return r; };
|
|
r.end = s => { r.body = s; };
|
|
return r;
|
|
}
|
|
const json = (r, status, obj) => { r.status(status); r.end(JSON.stringify(obj)); };
|
|
const reply = r => ({ code: r.code, ...JSON.parse(r.body) });
|
|
const req = (method, query = {}, { headers = {}, body } = {}) => ({ method, query, headers: { 'x-forwarded-for': '203.0.113.9', ...headers }, body });
|
|
|
|
test('validStamp: the publish stamp shape, nothing else', () => {
|
|
assert.equal(validStamp('2026-10-05T11:02:17Z.5e7b56f5'), true);
|
|
assert.equal(validStamp(''), false);
|
|
assert.equal(validStamp('a b'), false);
|
|
assert.equal(validStamp('x'.repeat(121)), false);
|
|
assert.equal(validStamp(42), false);
|
|
assert.equal(validStamp('has/slash'), false);
|
|
});
|
|
|
|
test('waitForChange: no since answers at once; a different stamp answers at once with changed', async () => {
|
|
const c = clock();
|
|
const r = await waitForChange({ read: async () => ({ stamp: 'A', at: null, added: [] }), since: '', now: c.now, sleep: c.sleep });
|
|
assert.deepEqual([r.stamp, r.changed, r.held_ms], ['A', false, 0]);
|
|
const r2 = await waitForChange({ read: async () => ({ stamp: 'B', at: null, added: [] }), since: 'A', now: c.now, sleep: c.sleep });
|
|
assert.deepEqual([r2.stamp, r2.changed, r2.held_ms], ['B', true, 0]);
|
|
});
|
|
|
|
test('waitForChange: holds in 2 s steps, answers the moment the stamp moves, else the unchanged stamp at the deadline', async () => {
|
|
const c = clock();
|
|
let cur = 'A';
|
|
let reads = 0;
|
|
const read = async () => { reads++; if (c.now() >= 1_000_000 + 7_000) cur = 'B'; return { stamp: cur, at: null, added: [] }; };
|
|
const r = await waitForChange({ read, since: 'A', now: c.now, sleep: c.sleep });
|
|
assert.deepEqual([r.stamp, r.changed, r.held_ms], ['B', true, 8_000]);
|
|
assert.equal(reads, 5);
|
|
const c2 = clock();
|
|
let reads2 = 0;
|
|
const r2 = await waitForChange({ read: async () => { reads2++; return { stamp: 'A', at: null, added: [] }; }, since: 'A', now: c2.now, sleep: c2.sleep });
|
|
assert.deepEqual([r2.stamp, r2.changed, r2.held_ms], ['A', false, HOLD_MS_MAX]);
|
|
assert.equal(reads2, Math.ceil(HOLD_MS_MAX / 2_000) + 1);
|
|
// a hold over the cap is clamped; a zero hold is one read
|
|
const c3 = clock();
|
|
const r3 = await waitForChange({ read: async () => ({ stamp: 'A' }), since: 'A', holdMs: 600_000, now: c3.now, sleep: c3.sleep });
|
|
assert.equal(r3.held_ms, HOLD_MS_MAX);
|
|
const c4 = clock();
|
|
const r4 = await waitForChange({ read: async () => ({ stamp: 'A' }), since: 'A', holdMs: 0, now: c4.now, sleep: c4.sleep });
|
|
assert.equal(r4.held_ms, 0);
|
|
});
|
|
|
|
test('waitForChange: an empty store answers the empty stamp (the app then waits its own floor)', async () => {
|
|
const c = clock();
|
|
const r = await waitForChange({ read: async () => null, since: 'A', now: c.now, sleep: c.sleep });
|
|
assert.deepEqual([r.stamp, r.changed, r.held_ms], ['', true, 0]);
|
|
});
|
|
|
|
test('RateLimit: the window, the reset, independent keys', () => {
|
|
const c = clock();
|
|
const rl = new RateLimit({ perMinute: 3, now: c.now });
|
|
assert.equal(rl.take('a'), null); assert.equal(rl.take('a'), null); assert.equal(rl.take('a'), null);
|
|
assert.equal(rl.take('a'), 60);
|
|
assert.equal(rl.take('b'), null);
|
|
c.set(c.now() + 59_000);
|
|
assert.equal(rl.take('a'), 1);
|
|
c.set(c.now() + 1_000);
|
|
assert.equal(rl.take('a'), null);
|
|
});
|
|
|
|
test('handler: GET before any POST answers the empty stamp; POST needs auth and a stamp; GET then sees it', async () => {
|
|
const db = fakeDb();
|
|
const c = clock();
|
|
const h = makeHandler({ sql: db.sql, authed: r => r.headers['x-relay-token'] === 'T' ? 'token' : null, readJson: async r => r.body || {}, json, now: c.now, sleep: c.sleep });
|
|
let r = res(); await h(req('GET', {}), r);
|
|
assert.deepEqual(reply(r), { code: 200, ok: true, stamp: '', at: null, added: [], changed: false, held_ms: 0 });
|
|
r = res(); await h(req('POST', {}, { body: { stamp: 'S1' } }), r);
|
|
assert.equal(reply(r).code, 401);
|
|
r = res(); await h(req('POST', {}, { headers: { 'x-relay-token': 'T' }, body: { stamp: '' } }), r);
|
|
assert.equal(reply(r).code, 400);
|
|
r = res(); await h(req('POST', {}, { headers: { 'x-relay-token': 'T' }, body: { stamp: 'S1', added: ['run-1', 7, ''] } }), r);
|
|
assert.deepEqual(reply(r), { code: 200, ok: true, stamp: 'S1', at: '2026-10-05T12:00:00.000Z', added: ['run-1', '7'] });
|
|
r = res(); await h(req('GET', { since: 'S0' }), r);
|
|
assert.deepEqual(reply(r), { code: 200, ok: true, stamp: 'S1', at: '2026-10-05T12:00:00.000Z', added: ['run-1', '7'], changed: true, held_ms: 0 });
|
|
r = res(); await h(req('PUT', {}), r);
|
|
assert.equal(reply(r).code, 405);
|
|
// the stamp may also come as ?stamp= with the token in the path (the /r/<token>/wake rewrite)
|
|
r = res(); await h(req('POST', { token: 'T', stamp: 'S2' }, { headers: { 'x-relay-token': 'T' }, body: {} }), r);
|
|
assert.equal(reply(r).stamp, 'S2');
|
|
});
|
|
|
|
test('handler: a held GET answers when a POST lands, with the held time', async () => {
|
|
const db = fakeDb();
|
|
db.set('S1');
|
|
const c = clock();
|
|
const h = makeHandler({ sql: db.sql, authed: () => 'token', readJson: async r => r.body || {}, json, now: c.now, sleep: async ms => { c.sleep(ms); if (c.now() >= 1_000_000 + 6_000 && !db.rows.some(x => x.stamp === 'S2')) db.set('S2', ['job-9']); } });
|
|
const r = res(); await h(req('GET', { since: 'S1' }), r);
|
|
const j = reply(r);
|
|
assert.deepEqual([j.stamp, j.changed, j.added, j.held_ms], ['S2', true, ['job-9'], 6_000]);
|
|
});
|
|
|
|
test('handler: the rate limit answers 429 with Retry-After and never holds; over the hold cap the answer is immediate', async () => {
|
|
const db = fakeDb();
|
|
db.set('S1');
|
|
const c = clock();
|
|
const h = makeHandler({ sql: db.sql, authed: () => null, readJson: async () => ({}), json, limiter: new RateLimit({ perMinute: 1, now: c.now }), now: c.now, sleep: c.sleep });
|
|
let r = res(); await h(req('GET', { since: 'S1', hold: 2 }), r);
|
|
assert.equal(reply(r).held_ms, 2_000);
|
|
r = res(); await h(req('GET', { since: 'S1' }), r);
|
|
assert.deepEqual([reply(r).code, reply(r).error, r.headers['Retry-After']], [429, 'rate limited', '58']);
|
|
const h2 = makeHandler({ sql: db.sql, authed: () => null, readJson: async () => ({}), json, holds: { n: HOLDS_MAX }, now: c.now, sleep: c.sleep });
|
|
r = res(); await h2(req('GET', { since: 'S1' }), r);
|
|
assert.deepEqual([reply(r).code, reply(r).held_ms], [200, 0]);
|
|
});
|
|
|
|
test('handler: a database error is a 500 with its message, not a hang', async () => {
|
|
const c = clock();
|
|
const h = makeHandler({ sql: async () => { throw new Error('connection refused'); }, authed: () => 'token', readJson: async () => ({ stamp: 'S1' }), json, now: c.now, sleep: c.sleep });
|
|
const r = res(); await h(req('GET', { since: 'S1' }), r);
|
|
assert.deepEqual([reply(r).code, reply(r).error], [500, 'connection refused']);
|
|
});
|
|
|
|
test('MF-11: a wake request that names its machine is recorded as a ping with the version and the last job; a bad id records nothing', async () => {
|
|
const db = fakeDb(); const c = clock();
|
|
db.set('2026-10-07T13:00:00Z.aaaa');
|
|
const h = makeHandler({ sql: db.sql, authed: () => 'token', readJson: async () => ({}), json, limiter: new RateLimit({ now: c.now }), now: c.now, sleep: c.sleep });
|
|
let r = res();
|
|
await h(req('GET', { since: '2026-10-07T13:00:00Z.aaaa', hold: '0', machine: '1ccfe586', v: '0.3.21', job: 'update-now-0319-1ccfe586' }), r);
|
|
assert.equal(reply(r).code, 200);
|
|
assert.deepEqual(db.seen, [{ machine: '1ccfe586', last_seen: '2026-10-07T13:00:00.000Z', version: '0.3.21', last_job: 'update-now-0319-1ccfe586' }]);
|
|
// a later poll without a job keeps the last job; a bad machine id is ignored
|
|
r = res(); await h(req('GET', { hold: '0', machine: '1ccfe586', v: '0.3.21' }), r);
|
|
assert.equal(db.seen[0].last_job, 'update-now-0319-1ccfe586');
|
|
r = res(); await h(req('GET', { hold: '0', machine: 'DROP TABLE', v: 'x' }), r);
|
|
assert.equal(reply(r).code, 200);
|
|
assert.equal(db.seen.length, 1);
|
|
assert.equal(await recordSeen(db.sql, { machine: 'zz', version: '', job: '' }), false);
|
|
assert.equal(validMachine('1ccfe586'), true);
|
|
assert.equal(validMachine('1CCFE586'), false);
|
|
const list = await seenList(db.sql);
|
|
assert.equal(list[0].machine, '1ccfe586');
|
|
assert.equal(list[0].last_seen, '2026-10-07T13:00:00.000Z');
|
|
// before the first ping the list is empty rather than an error
|
|
assert.deepEqual(await seenList(fakeDb().sql), []);
|
|
});
|
|
|
|
test('MF-11: the console reads a machine as silent after 15 minutes without a poll, with the time of the last one and the last job', () => {
|
|
const seen = { machine: '1ccfe586', last_seen: '2026-10-07T10:46:00.000Z', version: '0.3.19', last_job: 'update-now-0319-1ccfe586' };
|
|
const at = t => Date.parse(t);
|
|
assert.equal(PING_SILENT_S, 900);
|
|
const fresh = pingState(seen, at('2026-10-07T10:50:00Z'));
|
|
assert.equal(fresh.silent, false);
|
|
assert.equal(fresh.silent_s, 240);
|
|
const quiet = pingState(seen, at('2026-10-07T11:01:01Z'));
|
|
assert.deepEqual(quiet, { silent: true, never: false, silent_s: 901, since: '2026-10-07T10:46:00.000Z', last_job: 'update-now-0319-1ccfe586', version: '0.3.19' });
|
|
assert.equal(pingState(seen, at('2026-10-07T11:00:59Z')).silent, false, 'exactly inside the window');
|
|
assert.deepEqual(pingState(null, at('2026-10-07T11:00:00Z')), { silent: true, never: true, silent_s: null, since: null, last_job: '', version: '' });
|
|
});
|