"""Tests that daemon.iface stays in sync with registered server routes. Verifies a two-way contract: 1. Every iface constant has a matching handler registered. 2. Every registered handler has a matching iface constant. Run with: pytest tests/test_iface_sync.py -v """ from collections import defaultdict import pytest @pytest.fixture(autouse=True) def _load_handlers(): """Load all handler modules so registry is populated.""" # Import server to get registry, then load handlers from daemon import server # Force route registration server._register_routes() @pytest.fixture() def registry(): from daemon import server return server.registry @pytest.fixture() def iface_module(): import daemon.iface as iface return iface def _get_iface_pairs(iface_module): """Extract all (method, path) pairs from iface module.""" iface = iface_module return { name: val for name, val in iface.__dict__.items() if isinstance(val, tuple) and len(val) == 2 and isinstance(val[0], str) } def _get_registered_routes(registry): """Extract all (METHOD, path) keys from the registry.""" return {(method.upper(), path) for (method, path) in registry._routes} class TestIfaceSync: """Verify iface constants match registered routes.""" def test_iface_constants_non_empty(self, iface_module): pairs = _get_iface_pairs(iface_module) assert len(pairs) >= 50, f"Expected many iface constants, got {len(pairs)}" def test_iface_constants_have_registered_handlers(self, registry, iface_module): """Every iface constant should map to a registered route.""" registered = _get_registered_routes(registry) iface_pairs = _get_iface_pairs(iface_module) # These 5 routes go through add_route() in create_app(), not @registry.register add_route_paths = { "/health", "/status/all", "/status/refresh", "/ws", "/batch", } missing = [] for name, (method, path) in iface_pairs.items(): key = (method.upper(), path) if path not in add_route_paths and key not in registered: missing.append( ( name, { "method": method, "path": path, }, ) ) if missing: detail = "\n".join(f" {name}: {pair}" for name, pair in missing) pytest.fail( f"{len(missing)} iface constant(s) have no matching handler:\n{detail}" ) def test_registered_routes_have_iface_constants(self, registry, iface_module): """Every registered route should have a matching iface constant.""" registered = _get_registered_routes(registry) iface_pairs = _get_iface_pairs(iface_module) iface_keys = set(iface_pairs.values()) missing = registered - iface_keys if missing: detail = "\n".join(f" {method} {path}" for method, path in sorted(missing)) pytest.fail( f"{len(missing)} registered route(s) have no matching iface constant:\n{detail}" ) def test_no_duplicate_iface_constants(self, iface_module): """All iface constants should have unique (method, path) pairs.""" iface_pairs = _get_iface_pairs(iface_module) seen = defaultdict(list) for name, val in iface_pairs.items(): seen[val].append(name) dupes = {pair: names for pair, names in seen.items() if len(names) > 1} assert not dupes, "Duplicate iface constants:\n" + "".join( f" {pair}: {names}\n" for pair, names in dupes.items() ) class TestIfaceFormat: """Verify iface constants follow the expected format.""" def test_all_constants_are_tuples_of_str(self, iface_module): iface_pairs = _get_iface_pairs(iface_module) for name, val in iface_pairs.items(): assert isinstance(val, tuple), f"{name} should be a tuple" assert len(val) == 2, f"{name} should have length 2" assert isinstance(val[0], str), f"{name} method should be a string" assert isinstance(val[1], str), f"{name} path should be a string" def test_all_constants_have_uppercase_methods(self, iface_module): iface_pairs = _get_iface_pairs(iface_module) valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} for name, val in iface_pairs.items(): assert val[0].upper() in valid_methods, ( f"{name} has invalid method: {val[0]} — " f"should be one of {valid_methods}" ) def test_all_constants_have_leading_slash_path(self, iface_module): iface_pairs = _get_iface_pairs(iface_module) for name, val in iface_pairs.items(): assert val[1].startswith("/"), f"{name} path should start with /: {val[1]}"