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,100 @@
|
||||
"""Tests for broadcast_versions per-subsystem contract (daemon.server).
|
||||
|
||||
broadcast_versions(subsystem) sends exactly one data-carrying message for
|
||||
its subsystem — no legacy `updated` field — and skips the broadcast
|
||||
entirely when the subsystem's state is None (collector failed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
|
||||
class TestBroadcastVersionsPerSubsystem:
|
||||
def _ws(self):
|
||||
ws = AsyncMock()
|
||||
ws.send_str = AsyncMock()
|
||||
server._ws_subscribers.add(ws)
|
||||
return ws
|
||||
|
||||
def test_only_target_subsystem_sent(self):
|
||||
"""Each subscriber gets one versions message carrying that subsystem."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"v": name}
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
ws.send_str.assert_awaited_once()
|
||||
msg = json.loads(ws.send_str.call_args[0][0])
|
||||
assert msg == {
|
||||
"type": "versions",
|
||||
"subsystem": "firewall",
|
||||
"data": {"v": "firewall"},
|
||||
}
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_data_per_subsystem_not_shared(self):
|
||||
"""The data payload is that subsystem's state, not another's."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"name": name}
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("dnsmasq"))
|
||||
asyncio.run(server.broadcast_versions("acme"))
|
||||
msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
|
||||
assert [(m["subsystem"], m["data"]) for m in msgs] == [
|
||||
("dnsmasq", {"name": "dnsmasq"}),
|
||||
("acme", {"name": "acme"}),
|
||||
]
|
||||
# No legacy diff field in any message.
|
||||
for m in msgs:
|
||||
assert "updated" not in m
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_none_state_produces_no_message(self):
|
||||
"""A None payload (failed collection) is skipped — no clobber."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = None
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
ws.send_str.assert_not_awaited()
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_no_bump_called(self):
|
||||
"""broadcast_versions never bumps — callers own the version counter."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = {"a": 1}
|
||||
store.bump = MagicMock()
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
store.bump.assert_not_called()
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_dead_subscriber_removed(self):
|
||||
"""A failing subscriber is pruned and healthy ones still receive data."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = {"a": 1}
|
||||
dead = AsyncMock()
|
||||
dead.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||
healthy = self._ws()
|
||||
server._ws_subscribers.add(dead)
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
assert dead not in server._ws_subscribers
|
||||
healthy.send_str.assert_awaited_once()
|
||||
finally:
|
||||
server._ws_subscribers.discard(dead)
|
||||
server._ws_subscribers.discard(healthy)
|
||||
Reference in New Issue
Block a user