mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-31 04:02:03 +01:00
test: close Route Health Monitoring coverage gaps (PR #308)
Add 49 targeted tests across 8 files to lift patch coverage from 76.9% to ~95%+: - collector/cli.py (3% -> covered): TestImportRoutes (12 tests for _import_routes: happy-path, upsert, all error branches, exception handler) + TestRouteSeedImport (seed command integration). - api/routes/routes.py: update_route all-field branch, duplicate-label 409, observer sync, unresolved-nodes 400, hidden-route 404, get_route contributing-observers, create with observers, preview guards, history retention clamp. - collector/routes.py: evaluate_all_routes exception handler, preview early-return guards, evaluate_route_history < 2 expected, limit/until/ observer params on fetch_candidate_paths, _route_expected_hashes derive branch, _has_any_hops_per_day empty guard. - collector/subscriber.py: TestRouteEvaluatorScheduler (disabled, runs-and-stops, error-is-logged) mirroring TestSpamRescoreScheduler. - common/schemas/routes.py: RouteUpdate + RoutePreviewRequest validators (min nodes, distinct, clear_threshold). - collector/route_evaluator.py: evaluation exception handler. - common/config.py: route_evaluator_interval_seconds default + custom. - api/routes/packet_groups.py: mixed-visibility representative selection.
This commit is contained in:
8 files changed
+912
-2
No files matched your search
@@ -759,3 +759,36 @@ class TestPacketGroupRedaction:
|
||||
).json()
|
||||
assert data["redacted"] is False
|
||||
assert data["raw_hex"] == "SECRET"
|
||||
|
||||
def test_detail_representative_skips_redacted_reception(
|
||||
self, client_no_auth, channel_packets, api_db_session
|
||||
):
|
||||
"""A group with both a redacted and a visible reception serves the
|
||||
representative raw_hex/decoded from the first *visible* row."""
|
||||
pub_idx, adm_idx = channel_packets
|
||||
base = _now()
|
||||
api_db_session.add_all(
|
||||
[
|
||||
RawPacket(
|
||||
raw_hex="SECRET",
|
||||
packet_hash="MIXED_HASH",
|
||||
channel_idx=adm_idx,
|
||||
decoded={"scope": "admin"},
|
||||
received_at=base,
|
||||
),
|
||||
RawPacket(
|
||||
raw_hex="PUBLIC",
|
||||
packet_hash="MIXED_HASH",
|
||||
channel_idx=pub_idx,
|
||||
decoded={"scope": "community"},
|
||||
received_at=base.replace(microsecond=base.microsecond + 1),
|
||||
),
|
||||
]
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
data = client_no_auth.get("/api/v1/packet-groups/MIXED_HASH").json()
|
||||
assert data["redacted"] is False
|
||||
assert data["raw_hex"] == "PUBLIC"
|
||||
assert data["decoded"] == {"scope": "community"}
|
||||
assert data["reception_count"] == 2
|
||||
@@ -1,8 +1,16 @@
|
||||
"""Tests for route API endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from meshcore_hub.common.models import Node, Route, RouteNode
|
||||
from meshcore_hub.collector.routes import derive_expected_hash
|
||||
from meshcore_hub.common.models import (
|
||||
Node,
|
||||
PacketPathHop,
|
||||
RawPacket,
|
||||
Route,
|
||||
RouteNode,
|
||||
)
|
||||
|
||||
|
||||
def _make_node(session, public_key: str, name: str | None = None) -> Node:
|
||||
@@ -17,6 +25,40 @@ def _sample_nodes(session, count: int = 2) -> list[Node]:
|
||||
return [_make_node(session, k, f"Node-{i}") for i, k in enumerate(keys)]
|
||||
|
||||
|
||||
def _make_reception(
|
||||
session,
|
||||
observer_node_id: str | None,
|
||||
packet_hash: str,
|
||||
path_hashes: list[str],
|
||||
received_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Insert a RawPacket + PacketPathHop rows for a test reception."""
|
||||
ts = received_at or datetime.now(timezone.utc)
|
||||
rp_id = str(uuid4())
|
||||
session.add(
|
||||
RawPacket(
|
||||
id=rp_id,
|
||||
observer_node_id=observer_node_id,
|
||||
packet_hash=packet_hash,
|
||||
received_at=ts,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
for pos, nh in enumerate(path_hashes):
|
||||
session.add(
|
||||
PacketPathHop(
|
||||
raw_packet_id=rp_id,
|
||||
position=pos,
|
||||
node_hash=nh,
|
||||
packet_hash=packet_hash,
|
||||
received_at=ts,
|
||||
observer_node_id=observer_node_id,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return rp_id
|
||||
|
||||
|
||||
class TestListRoutes:
|
||||
def test_empty(self, client_no_auth):
|
||||
resp = client_no_auth.get("/api/v1/routes")
|
||||
@@ -426,3 +468,248 @@ class TestRouteHistory:
|
||||
assert entry["quality"] == "unknown"
|
||||
assert entry["state"] == "no_coverage"
|
||||
assert entry["matched_count"] == 0
|
||||
|
||||
def test_retention_clamp(self, client_no_auth, api_db_session, monkeypatch):
|
||||
route = _make_route_with_nodes(
|
||||
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_settings = MagicMock(effective_raw_packet_retention_days=2)
|
||||
monkeypatch.setattr(
|
||||
"meshcore_hub.api.routes.routes.get_collector_settings",
|
||||
lambda: mock_settings,
|
||||
)
|
||||
|
||||
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=7")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# retention=2 → days clamped to 2 → 3 entries (days + today)
|
||||
assert len(data["data"]) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional coverage: update fields, observers, hidden detail, preview guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateRouteFields:
|
||||
"""Cover the per-field update branches and edge cases of update_route."""
|
||||
|
||||
def _make_route(self, session) -> Route:
|
||||
nodes = _sample_nodes(session)
|
||||
route = Route(from_label="OldFrom", to_label="OldTo")
|
||||
session.add(route)
|
||||
session.flush()
|
||||
for pos, n in enumerate(nodes):
|
||||
session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=n.public_key[:2].upper(),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return route
|
||||
|
||||
def test_update_all_scalar_fields(self, client_no_auth, api_db_session):
|
||||
route = self._make_route(api_db_session)
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
json={
|
||||
"description": "updated desc",
|
||||
"visibility": "member",
|
||||
"match_width": 2,
|
||||
"window_hours": 48,
|
||||
"packet_count_threshold": 5,
|
||||
"clear_threshold": 8,
|
||||
"max_hop_span": 4,
|
||||
"enabled": False,
|
||||
"reversible": False,
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["description"] == "updated desc"
|
||||
assert data["visibility"] == "member"
|
||||
assert data["match_width"] == 2
|
||||
assert data["window_hours"] == 48
|
||||
assert data["packet_count_threshold"] == 5
|
||||
assert data["clear_threshold"] == 8
|
||||
assert data["max_hop_span"] == 4
|
||||
assert data["enabled"] is False
|
||||
assert data["reversible"] is False
|
||||
|
||||
def test_update_duplicate_label_409(self, client_no_auth, api_db_session):
|
||||
self._make_route(api_db_session)
|
||||
api_db_session.add(Route(from_label="Other", to_label="Pair"))
|
||||
api_db_session.commit()
|
||||
other = api_db_session.query(Route).filter(Route.from_label == "Other").first()
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{other.id}",
|
||||
json={"from_label": "OldFrom", "to_label": "OldTo"},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_update_observers(self, client_no_auth, api_db_session):
|
||||
route = self._make_route(api_db_session)
|
||||
obs = _make_node(api_db_session, "c" * 64, "Observer")
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
json={"observer_public_keys": [obs.public_key]},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["route_observers"]) == 1
|
||||
assert data["route_observers"][0]["public_key"] == obs.public_key
|
||||
|
||||
def test_update_unresolved_path_nodes_400(self, client_no_auth, api_db_session):
|
||||
route = self._make_route(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
json={
|
||||
"node_public_keys": ["ff" + "0" * 62, "ee" + "0" * 62],
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_not_found(self, client_no_auth):
|
||||
resp = client_no_auth.put(
|
||||
"/api/v1/routes/nonexistent",
|
||||
json={"description": "x"},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestGetRouteVisibility:
|
||||
def test_hidden_route_404(self, client_no_auth, api_db_session):
|
||||
route = _make_route_with_nodes(
|
||||
api_db_session,
|
||||
"Secret",
|
||||
"EP",
|
||||
["aa" + "0" * 62, "bb" + "0" * 62],
|
||||
visibility="admin",
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_admin_sees_hidden_route(self, client_no_auth, api_db_session):
|
||||
route = _make_route_with_nodes(
|
||||
api_db_session,
|
||||
"Secret",
|
||||
"EP",
|
||||
["aa" + "0" * 62, "bb" + "0" * 62],
|
||||
visibility="admin",
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_contributing_observers(self, client_no_auth, api_db_session):
|
||||
node_a = _make_node(api_db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(api_db_session, "bb" + "0" * 62)
|
||||
obs = _make_node(api_db_session, "cc" + "0" * 62, "Charlie")
|
||||
route = Route(from_label="A", to_label="B", match_width=1)
|
||||
api_db_session.add(route)
|
||||
api_db_session.flush()
|
||||
for pos, n in enumerate([node_a, node_b]):
|
||||
api_db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=derive_expected_hash(n.public_key, 1),
|
||||
)
|
||||
)
|
||||
_make_reception(
|
||||
api_db_session,
|
||||
observer_node_id=obs.id,
|
||||
packet_hash="pkt-contrib",
|
||||
path_hashes=["AA", "BB"],
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["contributing_observers"]) == 1
|
||||
assert data["contributing_observers"][0]["node_id"] == obs.id
|
||||
assert data["contributing_observers"][0]["match_count"] == 1
|
||||
assert len(data["recent_matches"]) == 1
|
||||
|
||||
|
||||
class TestCreateWithObservers:
|
||||
def test_create_with_observers(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
obs = _make_node(api_db_session, "c" * 64, "Observer")
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={
|
||||
"from_label": "Alpha",
|
||||
"to_label": "Beta",
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"observer_public_keys": [obs.public_key],
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert len(data["route_observers"]) == 1
|
||||
assert data["route_observers"][0]["public_key"] == obs.public_key
|
||||
|
||||
|
||||
class TestPreviewGuards:
|
||||
def test_preview_unresolved_nodes(self, client_no_auth, api_db_session):
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes/preview",
|
||||
json={
|
||||
"node_public_keys": ["ff" + "0" * 62, "ee" + "0" * 62],
|
||||
"match_width": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["matched_count"] == 0
|
||||
assert data["quality"] == "unknown"
|
||||
assert data["state"] == "no_coverage"
|
||||
|
||||
def test_preview_with_observers(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
obs = _make_node(api_db_session, "c" * 64, "Obs")
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes/preview",
|
||||
json={
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"observer_public_keys": [obs.public_key],
|
||||
"match_width": 1,
|
||||
"window_hours": 24,
|
||||
"packet_count_threshold": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -5,9 +5,14 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from meshcore_hub.collector.cli import _import_channels, collector
|
||||
from meshcore_hub.collector.cli import _import_channels, _import_routes, collector
|
||||
from meshcore_hub.collector.routes import derive_expected_hash
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.route import Route
|
||||
from meshcore_hub.common.models.route_node import RouteNode
|
||||
from meshcore_hub.common.models.route_observer import RouteObserver
|
||||
|
||||
|
||||
def _make_mock_settings(db_url: str, seed_home: str = "/tmp/seed") -> MagicMock:
|
||||
@@ -456,3 +461,254 @@ class TestChannelSeedImport:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No seed files found" in result.output
|
||||
|
||||
|
||||
# 64-char lowercase hex public keys used across the route seed tests.
|
||||
_PK_A = "aa" + "0" * 62
|
||||
_PK_B = "bb" + "0" * 62
|
||||
_PK_C = "cc" + "0" * 62
|
||||
|
||||
|
||||
def _write_routes_yaml(path, body: str) -> str:
|
||||
path.write_text(body)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestImportRoutes:
|
||||
"""Unit tests for ``_import_routes`` (routes.yaml seed loader)."""
|
||||
|
||||
def _seed_two_nodes(self, db_manager) -> None:
|
||||
with db_manager.session_scope() as session:
|
||||
session.add(Node(public_key=_PK_A, name="Alpha"))
|
||||
session.add(Node(public_key=_PK_B, name="Beta"))
|
||||
session.add(Node(public_key=_PK_C, name="Charlie"))
|
||||
|
||||
def test_import_creates_route(self, db_manager, tmp_path):
|
||||
self._seed_two_nodes(db_manager)
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
(
|
||||
"routes:\n"
|
||||
" - from: Alpha\n"
|
||||
" to: Beta\n"
|
||||
f" path:\n - '{_PK_A}'\n - '{_PK_B}'\n"
|
||||
" match_width: 1\n"
|
||||
" visibility: community\n"
|
||||
" description: a route\n"
|
||||
" window_hours: 48\n"
|
||||
" packet_count_threshold: 5\n"
|
||||
" clear_threshold: 8\n"
|
||||
" max_hop_span: 3\n"
|
||||
" enabled: true\n"
|
||||
" reversible: false\n"
|
||||
f" observers:\n - '{_PK_C}'\n"
|
||||
),
|
||||
)
|
||||
|
||||
stats = _import_routes(file_path=fp, db=db_manager, verbose=True)
|
||||
|
||||
assert stats["created"] == 1
|
||||
assert stats["updated"] == 0
|
||||
assert stats["errors"] == []
|
||||
with db_manager.session_scope() as session:
|
||||
route = session.query(Route).filter(Route.from_label == "Alpha").first()
|
||||
assert route.to_label == "Beta"
|
||||
assert route.visibility == "community"
|
||||
assert route.match_width == 1
|
||||
assert route.window_hours == 48
|
||||
assert route.packet_count_threshold == 5
|
||||
assert route.clear_threshold == 8
|
||||
assert route.max_hop_span == 3
|
||||
assert route.enabled is True
|
||||
assert route.reversible is False
|
||||
assert route.description == "a route"
|
||||
nodes = (
|
||||
session.query(RouteNode)
|
||||
.filter(RouteNode.route_id == route.id)
|
||||
.order_by(RouteNode.position)
|
||||
.all()
|
||||
)
|
||||
assert len(nodes) == 2
|
||||
assert nodes[0].expected_hash == derive_expected_hash(_PK_A, 1)
|
||||
assert nodes[1].expected_hash == derive_expected_hash(_PK_B, 1)
|
||||
obs = (
|
||||
session.query(RouteObserver)
|
||||
.filter(RouteObserver.route_id == route.id)
|
||||
.all()
|
||||
)
|
||||
assert len(obs) == 1
|
||||
|
||||
def test_import_upserts_existing_route(self, db_manager, tmp_path):
|
||||
self._seed_two_nodes(db_manager)
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
(
|
||||
"routes:\n"
|
||||
" - from: Alpha\n"
|
||||
" to: Beta\n"
|
||||
f" path:\n - '{_PK_A}'\n - '{_PK_B}'\n"
|
||||
),
|
||||
)
|
||||
|
||||
first = _import_routes(file_path=fp, db=db_manager)
|
||||
second = _import_routes(file_path=fp, db=db_manager)
|
||||
|
||||
assert first["created"] == 1 and first["updated"] == 0
|
||||
assert second["created"] == 0 and second["updated"] == 1
|
||||
assert second["errors"] == []
|
||||
with db_manager.session_scope() as session:
|
||||
routes = session.query(Route).filter(Route.from_label == "Alpha").all()
|
||||
assert len(routes) == 1
|
||||
nodes = (
|
||||
session.query(RouteNode)
|
||||
.filter(RouteNode.route_id == routes[0].id)
|
||||
.all()
|
||||
)
|
||||
assert len(nodes) == 2
|
||||
|
||||
def test_import_empty_file(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(tmp_path / "routes.yaml", "")
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats == {"created": 0, "updated": 0, "errors": []}
|
||||
|
||||
def test_import_non_dict_top_level(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(tmp_path / "routes.yaml", "- just\n- a\n- list\n")
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats == {"created": 0, "updated": 0, "errors": []}
|
||||
|
||||
def test_import_missing_routes_key(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(tmp_path / "routes.yaml", "other: value\n")
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert "must have a list under the 'routes:' key" in stats["errors"][0]
|
||||
|
||||
def test_import_routes_not_a_list(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(tmp_path / "routes.yaml", "routes: notalist\n")
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert "must have a list under the 'routes:' key" in stats["errors"][0]
|
||||
|
||||
def test_import_entry_not_a_dict(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(tmp_path / "routes.yaml", "routes:\n - justastring\n")
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert any("entry must be a dict" in e for e in stats["errors"])
|
||||
|
||||
def test_import_missing_from_to(self, db_manager, tmp_path):
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
"routes:\n - path:\n - foo\n - bar\n",
|
||||
)
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert any("'from' and 'to' are required" in e for e in stats["errors"])
|
||||
|
||||
def test_import_path_too_short(self, db_manager, tmp_path):
|
||||
self._seed_two_nodes(db_manager)
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
f"routes:\n - from: A\n to: B\n path:\n - '{_PK_A}'\n",
|
||||
)
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert any("path needs >= 2 nodes" in e for e in stats["errors"])
|
||||
|
||||
def test_import_path_node_not_found(self, db_manager, tmp_path):
|
||||
self._seed_two_nodes(db_manager)
|
||||
missing = "ff" + "0" * 62
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
(
|
||||
"routes:\n - from: A\n to: B\n"
|
||||
f" path:\n - '{_PK_A}'\n - '{missing}'\n"
|
||||
),
|
||||
)
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert stats["created"] == 0
|
||||
assert any("path node" in e and "not found" in e for e in stats["errors"])
|
||||
|
||||
def test_import_observer_not_found_warns(self, db_manager, tmp_path, capsys):
|
||||
self._seed_two_nodes(db_manager)
|
||||
missing = "ff" + "0" * 62
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
(
|
||||
"routes:\n - from: A\n to: B\n"
|
||||
f" path:\n - '{_PK_A}'\n - '{_PK_B}'\n"
|
||||
f" observers:\n - '{missing}'\n"
|
||||
),
|
||||
)
|
||||
stats = _import_routes(file_path=fp, db=db_manager, verbose=True)
|
||||
assert stats["created"] == 1
|
||||
assert stats["errors"] == []
|
||||
captured = capsys.readouterr()
|
||||
assert "observer node" in captured.out and "not found" in captured.out
|
||||
|
||||
def test_import_entry_exception_is_caught(self, db_manager, tmp_path, monkeypatch):
|
||||
self._seed_two_nodes(db_manager)
|
||||
fp = _write_routes_yaml(
|
||||
tmp_path / "routes.yaml",
|
||||
(
|
||||
"routes:\n - from: A\n to: B\n"
|
||||
f" path:\n - '{_PK_A}'\n - '{_PK_B}'\n"
|
||||
),
|
||||
)
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("meshcore_hub.collector.routes.derive_expected_hash", _boom)
|
||||
stats = _import_routes(file_path=fp, db=db_manager)
|
||||
assert any("boom" in e for e in stats["errors"])
|
||||
|
||||
|
||||
class TestRouteSeedImport:
|
||||
"""Integration tests for the ``seed`` command with routes.yaml."""
|
||||
|
||||
def test_seed_imports_routes_yaml(self, tmp_path):
|
||||
runner = CliRunner()
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
|
||||
pk_a = "11" + "0" * 62
|
||||
pk_b = "22" + "0" * 62
|
||||
(seed_dir / "routes.yaml").write_text(
|
||||
"routes:\n"
|
||||
" - from: NodeA\n"
|
||||
" to: NodeB\n"
|
||||
f" path:\n - '{pk_a}'\n - '{pk_b}'\n"
|
||||
)
|
||||
|
||||
db_path = tmp_path / "seed_route.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
db = DatabaseManager(db_url)
|
||||
db.create_tables()
|
||||
with db.session_scope() as session:
|
||||
session.add(Node(public_key=pk_a, name="NodeA"))
|
||||
session.add(Node(public_key=pk_b, name="NodeB"))
|
||||
db.dispose()
|
||||
|
||||
mock_settings = _make_mock_settings(db_url, seed_home=str(seed_dir))
|
||||
with patch(
|
||||
"meshcore_hub.common.config.get_collector_settings",
|
||||
return_value=mock_settings,
|
||||
):
|
||||
result = runner.invoke(
|
||||
collector,
|
||||
["--database-url", db_url, "--seed-home", str(seed_dir), "seed"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Routes: 1 created" in result.output
|
||||
|
||||
db = DatabaseManager(db_url)
|
||||
with db.session_scope() as session:
|
||||
route = session.query(Route).filter(Route.from_label == "NodeA").first()
|
||||
assert route is not None
|
||||
assert route.to_label == "NodeB"
|
||||
nodes = (
|
||||
session.query(RouteNode).filter(RouteNode.route_id == route.id).all()
|
||||
)
|
||||
assert len(nodes) == 2
|
||||
db.dispose()
|
||||
@@ -113,3 +113,17 @@ class TestRunEvaluation:
|
||||
assert result.quality == RouteQuality.CLEAR.value
|
||||
assert result.threshold == 3
|
||||
assert result.effective_clear == 6
|
||||
|
||||
def test_evaluation_error_logged(self, db_manager, db_session, monkeypatch):
|
||||
"""An exception evaluating one route is caught; count stays 0."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
_make_route(db_session, "R1", [node_a, node_b])
|
||||
db_session.commit()
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("eval failed")
|
||||
|
||||
monkeypatch.setattr("meshcore_hub.collector.routes.evaluate_route", _boom)
|
||||
count = run_evaluation(db_manager, now=_NOW)
|
||||
assert count == 0
|
||||
@@ -6,6 +6,8 @@ from uuid import uuid4
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.collector.routes import (
|
||||
_has_any_hops_per_day,
|
||||
_route_expected_hashes,
|
||||
derive_expected_hash,
|
||||
derive_quality,
|
||||
detect_observed_widths,
|
||||
@@ -14,6 +16,7 @@ from meshcore_hub.collector.routes import (
|
||||
evaluate_route,
|
||||
evaluate_route_day,
|
||||
evaluate_route_history,
|
||||
fetch_candidate_paths,
|
||||
is_subsequence,
|
||||
preview_route,
|
||||
prefix_collision_counts,
|
||||
@@ -828,3 +831,161 @@ class TestEvaluateRouteHistory:
|
||||
marginal_date = (now - timedelta(days=2)).date()
|
||||
assert by_date[clear_date][0] == RouteQuality.CLEAR.value
|
||||
assert by_date[marginal_date][0] == RouteQuality.MARGINAL.value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch-coverage tests (guards, exception handlers, optional-param paths)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateAllRoutesError:
|
||||
def test_exception_logged_and_continues(self, db_session, monkeypatch):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
_make_route(db_session, "Boom", [node_a, node_b])
|
||||
db_session.commit()
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("eval failed")
|
||||
|
||||
monkeypatch.setattr("meshcore_hub.collector.routes.evaluate_route", _boom)
|
||||
results = evaluate_all_routes(db_session, now=_NOW)
|
||||
# Route is absent from results (exception swallowed), but no raise.
|
||||
assert results == {}
|
||||
|
||||
|
||||
class TestPreviewGuards:
|
||||
def test_single_node_guard(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
result = preview_route(db_session, {"node_ids": [node_a.id]}, since)
|
||||
assert result["matched_count"] == 0
|
||||
assert result["quality"] == RouteQuality.UNKNOWN.value
|
||||
assert result["state"] == RouteState.NO_COVERAGE.value
|
||||
assert result["truncated"] is False
|
||||
|
||||
def test_unresolvable_nodes_guard(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
result = preview_route(
|
||||
db_session,
|
||||
{"node_ids": [node_a.id, str(uuid4())]},
|
||||
since,
|
||||
)
|
||||
assert result["matched_count"] == 0
|
||||
assert result["state"] == RouteState.NO_COVERAGE.value
|
||||
|
||||
def test_preview_with_observers(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
obs = _make_node(db_session, "cc" + "0" * 62, "Obs")
|
||||
_make_reception(db_session, obs.id, "pkt1", ["AA", "BB"], received_at=_NOW)
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
result = preview_route(
|
||||
db_session,
|
||||
{
|
||||
"node_ids": [node_a.id, node_b.id],
|
||||
"match_width": 1,
|
||||
"observer_ids": [obs.id],
|
||||
"packet_count_threshold": 3,
|
||||
},
|
||||
since,
|
||||
)
|
||||
assert result["matched_count"] == 1
|
||||
assert str(obs.id) in result["contributing_observers"]
|
||||
|
||||
|
||||
class TestRouteHistoryGuards:
|
||||
def test_single_expected_hash_returns_unknown(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
route = _make_route(db_session, "Solo", [node_a])
|
||||
db_session.commit()
|
||||
|
||||
results = evaluate_route_history(db_session, route, days=3, now=_NOW)
|
||||
assert len(results) == 3
|
||||
for _d, quality, state, count in results:
|
||||
assert quality == RouteQuality.UNKNOWN.value
|
||||
assert state == RouteState.NO_COVERAGE.value
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestFetchCandidatePathsParams:
|
||||
def test_limit_caps_groups(self, db_session):
|
||||
for i in range(3):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
paths = fetch_candidate_paths(db_session, "AA", since, limit=1)
|
||||
assert len(paths) == 1
|
||||
|
||||
def test_until_excludes_later(self, db_session):
|
||||
_make_reception(
|
||||
db_session, None, "old", ["AA"], received_at=_NOW - timedelta(hours=10)
|
||||
)
|
||||
_make_reception(
|
||||
db_session, None, "new", ["AA"], received_at=_NOW - timedelta(hours=1)
|
||||
)
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
paths = fetch_candidate_paths(
|
||||
db_session, "AA", since, until=_NOW - timedelta(hours=5)
|
||||
)
|
||||
assert len(paths) == 1
|
||||
hops = list(paths.values())[0]
|
||||
assert hops[0]["packet_hash"] == "old"
|
||||
|
||||
def test_observer_filter(self, db_session):
|
||||
obs1 = _make_node(db_session, "11" + "0" * 62)
|
||||
obs2 = _make_node(db_session, "22" + "0" * 62)
|
||||
_make_reception(db_session, obs1.id, "pkt1", ["AA"], received_at=_NOW)
|
||||
_make_reception(db_session, obs2.id, "pkt2", ["AA"], received_at=_NOW)
|
||||
db_session.commit()
|
||||
since = _NOW - timedelta(hours=24)
|
||||
|
||||
paths = fetch_candidate_paths(db_session, "AA", since, observer_ids=[obs1.id])
|
||||
assert len(paths) == 1
|
||||
hops = list(paths.values())[0]
|
||||
assert hops[0]["observer_node_id"] == obs1.id
|
||||
|
||||
|
||||
class TestRouteExpectedHashesDerive:
|
||||
def test_derives_when_expected_hash_missing(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = Route(from_label="A", to_label="B", match_width=1)
|
||||
db_session.add(route)
|
||||
db_session.flush()
|
||||
# node_a gets expected_hash; node_b gets None (forces elif-derive)
|
||||
db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=node_a.id,
|
||||
position=0,
|
||||
expected_hash="AA",
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=node_b.id,
|
||||
position=1,
|
||||
expected_hash=None,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
result = _route_expected_hashes(route)
|
||||
assert result == ["AA", derive_expected_hash(node_b.public_key, 1)]
|
||||
|
||||
|
||||
class TestHasAnyHopsPerDayGuard:
|
||||
def test_empty_day_starts_returns_empty(self, db_session):
|
||||
assert _has_any_hops_per_day(db_session, [], []) == set()
|
||||
@@ -1507,3 +1507,109 @@ class TestSpamRescoreScheduler:
|
||||
sub._stop_spam_rescore_scheduler()
|
||||
assert sub._spam_rescore_thread is not None
|
||||
assert not sub._spam_rescore_thread.is_alive()
|
||||
|
||||
|
||||
class TestRouteEvaluatorScheduler:
|
||||
"""Tests for the background route health evaluator scheduler."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mqtt_client(self):
|
||||
client = MagicMock()
|
||||
client.topic_builder = MagicMock()
|
||||
client.topic_builder.prefix = "meshcore"
|
||||
return client
|
||||
|
||||
def test_disabled_does_not_start_thread(
|
||||
self, mock_mqtt_client, db_manager, monkeypatch
|
||||
):
|
||||
"""With interval=0, no evaluator thread is created."""
|
||||
from meshcore_hub.common.config import CollectorSettings
|
||||
|
||||
real_init = CollectorSettings.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
real_init(self, *args, _env_file=None, **kwargs)
|
||||
self.route_evaluator_interval_seconds = 0
|
||||
|
||||
monkeypatch.setattr(CollectorSettings, "__init__", patched_init)
|
||||
|
||||
sub = Subscriber(mock_mqtt_client, db_manager)
|
||||
sub._start_route_evaluator_scheduler()
|
||||
assert sub._route_evaluator_thread is None
|
||||
sub._stop_route_evaluator_scheduler()
|
||||
|
||||
def test_enabled_runs_evaluation_and_stops(
|
||||
self, mock_mqtt_client, db_manager, monkeypatch
|
||||
):
|
||||
"""Enabled scheduler spawns a thread, runs evaluation, joins on stop."""
|
||||
import threading
|
||||
|
||||
from meshcore_hub.common.config import CollectorSettings
|
||||
|
||||
real_init = CollectorSettings.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
real_init(self, *args, _env_file=None, **kwargs)
|
||||
self.route_evaluator_interval_seconds = 1
|
||||
|
||||
monkeypatch.setattr(CollectorSettings, "__init__", patched_init)
|
||||
monkeypatch.setattr(
|
||||
"meshcore_hub.collector.subscriber.time.sleep", lambda *_: None
|
||||
)
|
||||
|
||||
sub = Subscriber(mock_mqtt_client, db_manager)
|
||||
called = threading.Event()
|
||||
|
||||
def fake_run_evaluation(db):
|
||||
called.set()
|
||||
sub._running = False
|
||||
return 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
"meshcore_hub.collector.route_evaluator.run_evaluation",
|
||||
fake_run_evaluation,
|
||||
)
|
||||
|
||||
sub._running = True
|
||||
sub._start_route_evaluator_scheduler()
|
||||
assert called.wait(timeout=5.0)
|
||||
sub._stop_route_evaluator_scheduler()
|
||||
|
||||
assert sub._route_evaluator_thread is not None
|
||||
assert not sub._route_evaluator_thread.is_alive()
|
||||
|
||||
def test_evaluator_error_is_logged(self, mock_mqtt_client, db_manager, monkeypatch):
|
||||
"""An exception in run_evaluation is caught and logged."""
|
||||
import threading
|
||||
|
||||
from meshcore_hub.common.config import CollectorSettings
|
||||
|
||||
real_init = CollectorSettings.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
real_init(self, *args, _env_file=None, **kwargs)
|
||||
self.route_evaluator_interval_seconds = 1
|
||||
|
||||
monkeypatch.setattr(CollectorSettings, "__init__", patched_init)
|
||||
monkeypatch.setattr(
|
||||
"meshcore_hub.collector.subscriber.time.sleep", lambda *_: None
|
||||
)
|
||||
|
||||
sub = Subscriber(mock_mqtt_client, db_manager)
|
||||
called = threading.Event()
|
||||
|
||||
def boom(db):
|
||||
sub._running = False
|
||||
called.set()
|
||||
raise RuntimeError("eval failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"meshcore_hub.collector.route_evaluator.run_evaluation", boom
|
||||
)
|
||||
|
||||
sub._running = True
|
||||
sub._start_route_evaluator_scheduler()
|
||||
assert called.wait(timeout=5.0)
|
||||
sub._stop_route_evaluator_scheduler()
|
||||
assert sub._route_evaluator_thread is not None
|
||||
assert not sub._route_evaluator_thread.is_alive()
|
||||
@@ -118,6 +118,21 @@ class TestCollectorSettings:
|
||||
|
||||
assert settings.channel_refresh_interval_seconds == 60
|
||||
|
||||
def test_route_evaluator_interval_seconds_default(self) -> None:
|
||||
"""Route evaluator interval defaults to 60."""
|
||||
settings = CollectorSettings(_env_file=None)
|
||||
|
||||
assert settings.route_evaluator_interval_seconds == 60
|
||||
|
||||
def test_route_evaluator_interval_seconds_custom(self) -> None:
|
||||
"""Route evaluator interval can be overridden (0 disables)."""
|
||||
settings = CollectorSettings(
|
||||
_env_file=None,
|
||||
route_evaluator_interval_seconds=0,
|
||||
)
|
||||
|
||||
assert settings.route_evaluator_interval_seconds == 0
|
||||
|
||||
def test_channels_file_path(self) -> None:
|
||||
"""channels_file property resolves to seed_home/channels.yaml."""
|
||||
settings = CollectorSettings(_env_file=None, seed_home="/seed/data")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for route schema validators (RouteUpdate, RoutePreviewRequest)."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from meshcore_hub.common.schemas.routes import RoutePreviewRequest, RouteUpdate
|
||||
|
||||
|
||||
class TestRouteUpdateValidator:
|
||||
def test_min_two_nodes(self):
|
||||
with pytest.raises(ValidationError, match="At least 2 path nodes"):
|
||||
RouteUpdate(node_public_keys=["a" * 64])
|
||||
|
||||
def test_distinct_nodes(self):
|
||||
with pytest.raises(ValidationError, match="Path nodes must be distinct"):
|
||||
RouteUpdate(node_public_keys=["a" * 64, "a" * 64])
|
||||
|
||||
def test_clear_threshold_must_exceed_packet_count(self):
|
||||
with pytest.raises(ValidationError, match="clear_threshold must be >"):
|
||||
RouteUpdate(packet_count_threshold=5, clear_threshold=5)
|
||||
|
||||
def test_clear_threshold_ok_when_only_one_set(self):
|
||||
route = RouteUpdate(clear_threshold=5)
|
||||
assert route.clear_threshold == 5
|
||||
|
||||
def test_clear_threshold_gt_packet_count_ok(self):
|
||||
route = RouteUpdate(packet_count_threshold=3, clear_threshold=6)
|
||||
assert route.clear_threshold == 6
|
||||
|
||||
|
||||
class TestRoutePreviewValidator:
|
||||
def test_distinct_nodes(self):
|
||||
with pytest.raises(ValidationError, match="Path nodes must be distinct"):
|
||||
RoutePreviewRequest(node_public_keys=["a" * 64, "a" * 64])
|
||||
|
||||
def test_min_two_nodes(self):
|
||||
with pytest.raises(ValidationError, match="At least 2 path nodes"):
|
||||
RoutePreviewRequest(node_public_keys=["a" * 64])
|
||||
Reference in new issue
Block a user