diff --git a/tests/test_api/test_packet_groups.py b/tests/test_api/test_packet_groups.py index b2535c3..3339a37 100644 --- a/tests/test_api/test_packet_groups.py +++ b/tests/test_api/test_packet_groups.py @@ -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 diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 82390c5..e4e5d07 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -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 diff --git a/tests/test_collector/test_cli.py b/tests/test_collector/test_cli.py index 6fc274e..cff031c 100644 --- a/tests/test_collector/test_cli.py +++ b/tests/test_collector/test_cli.py @@ -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() diff --git a/tests/test_collector/test_route_evaluator.py b/tests/test_collector/test_route_evaluator.py index 0b5f473..a4be645 100644 --- a/tests/test_collector/test_route_evaluator.py +++ b/tests/test_collector/test_route_evaluator.py @@ -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 diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index d6b9481..57bd4a5 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -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() diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index b56da06..4deda9b 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -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() diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index d9877d0..705dcc2 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -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") diff --git a/tests/test_common/test_schemas_routes.py b/tests/test_common/test_schemas_routes.py new file mode 100644 index 0000000..c1aa856 --- /dev/null +++ b/tests/test_common/test_schemas_routes.py @@ -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])