ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for hoover/model.js modelSet() — the WS data-streaming entry point.
|
||||
*
|
||||
* modelSet() bypasses the fetch cycle: it assigns directly to the reactive
|
||||
* model, clears loading unconditionally (schema defaults mean model.data is
|
||||
* never null), clears error, and never sets refreshing.
|
||||
*
|
||||
* model.js imports only reactivity.js — DOM-free at import, so the tests
|
||||
* run under plain node (same pattern as test-auth-model.js).
|
||||
*
|
||||
* Run with `node tests/test-model-set.js`.
|
||||
*/
|
||||
|
||||
import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js';
|
||||
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const tests = [];
|
||||
|
||||
function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEq(a, b, msg) {
|
||||
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||
}
|
||||
|
||||
/** Deep equality for objects/arrays (assertEq is reference-based). */
|
||||
function assertDeep(a, b, msg) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||
}
|
||||
|
||||
/* ── Tests ─────────────────────────────────────────────────── */
|
||||
|
||||
test('modelSet assigns data and clears loading unconditionally', () => {
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
defaultData: SUBSYSTEMS.firewall.defaults,
|
||||
fetch: async () => ({}),
|
||||
});
|
||||
const m = getModel('firewall');
|
||||
assertEq(m.loading, true, 'registering model is loading');
|
||||
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'schema defaults pre-populated');
|
||||
assertEq(m.refreshing, false, 'not refreshing at rest');
|
||||
|
||||
modelSet('firewall', { zones: { public: {} }, pending: { pending: [] } });
|
||||
|
||||
assertEq(m.loading, false, 'real data ends the initial load');
|
||||
assertEq(m.refreshing, false, 'modelSet never sets refreshing');
|
||||
assertEq(m.error, null, 'modelSet clears error');
|
||||
assertDeep(m.data.zones?.public, {}, 'data assigned to the reactive model');
|
||||
});
|
||||
|
||||
test('modelSet is a no-op for an unregistered name', () => {
|
||||
assertEq(typeof modelSet('no-such-model', { x: 1 }), 'undefined', 'no throw, no return');
|
||||
});
|
||||
|
||||
test('modelSet clears a fetch-set error and replaces error-state data', async () => {
|
||||
modelRegister('dnsmasq', {
|
||||
subsystem: 'dnsmasq',
|
||||
defaultData: SUBSYSTEMS.dnsmasq.defaults,
|
||||
fetch: async () => { throw new Error('boom'); },
|
||||
});
|
||||
const m = getModel('dnsmasq');
|
||||
await modelFetch('dnsmasq');
|
||||
assertEq(m.error, 'boom', 'fetch failure sets error');
|
||||
assertEq(m.loading, false, 'fetch failure clears loading');
|
||||
assertEq(m.data, SUBSYSTEMS.dnsmasq.defaults, 'failed fetch keeps schema defaults');
|
||||
|
||||
const delta = { leases: [{ mac: 'aa:bb', ip: '10.0.0.9' }] };
|
||||
modelSet('dnsmasq', delta);
|
||||
assertEq(m.error, null, 'subsequent real data clears the error');
|
||||
assertEq(m.data.leases?.[0]?.mac, 'aa:bb', 'delta replaces default data');
|
||||
});
|
||||
|
||||
test('modelSet works repeatedly without flag corruption', () => {
|
||||
modelRegister('system', {
|
||||
subsystem: 'system',
|
||||
defaultData: SUBSYSTEMS.system.defaults,
|
||||
fetch: async () => ({}),
|
||||
});
|
||||
const m = getModel('system');
|
||||
modelSet('system', { load: { load1: 0.1 } });
|
||||
modelSet('system', { load: { load1: 0.2 } });
|
||||
assertEq(m.loading, false, 'still not loading');
|
||||
assertEq(m.refreshing, false, 'still not refreshing');
|
||||
assertEq(m.error, null, 'no error');
|
||||
assertEq(m.data.load?.load1, 0.2, 'latest delta wins');
|
||||
});
|
||||
|
||||
/* ── Runner ────────────────────────────────────────────────── */
|
||||
|
||||
(async () => {
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(` \u2717 ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
})();
|
||||
Reference in New Issue
Block a user