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:
Louis King committed 2026-07-16 23:30:28 +01:00
1 parent 8ce40fc75e
commit a64d0b5561
8 files changed
+912 -2

No files matched your search

+33
View File
@@ -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
+288 -1
View File
@@ -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
+257 -1
View File
@@ -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
+161
View File
@@ -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()
+106
View File
@@ -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()
+15
View File
@@ -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")
+38
View File
@@ -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])