"""API test fixtures. The ``db_url`` / ``db_schema`` fixtures that provide the per-xdist-worker Postgres schema live in the shared ``tests/conftest.py`` so the collector suite can reuse the same database; they are inherited here. """ from contextlib import contextmanager from datetime import datetime, timezone from urllib.parse import urlparse import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import sessionmaker from meshcore_hub.api.app import create_app from meshcore_hub.api.dependencies import ( get_db_session, get_mqtt_client, get_db_manager, ) from meshcore_hub.common.database import DatabaseManager, create_database_engine from meshcore_hub.common.redis import RedisCacheBackend from meshcore_hub.common.models import ( Advertisement, Base, Channel, EventObserver, Message, Node, NodeTag, Telemetry, TracePath, UserProfile, UserProfileNode, ) @pytest.fixture(scope="session") def api_db_engine(db_url: str, db_schema: str): """Session-scoped worker-schema Postgres engine. Schema built once. Uses the production ``create_database_engine`` factory so tests exercise the same ``connect_args`` (``search_path`` + ``-ctimezone=UTC``) as the running services. Each xdist worker has its own schema (see ``db_schema``), so no locking is needed. """ engine = create_database_engine(db_url, schema=db_schema) Base.metadata.drop_all(engine) Base.metadata.create_all(engine) yield engine Base.metadata.drop_all(engine) engine.dispose() def _truncate_all(engine) -> None: """Delete rows from every table in child-first order (FK-safe).""" with engine.begin() as conn: for table in reversed(Base.metadata.sorted_tables): conn.execute(table.delete()) @pytest.fixture def api_db_session(api_db_engine): """Per-test session bound to the shared session-scoped engine. Rows are truncated at teardown so each test starts with empty tables without paying schema-build cost. Tests must ``commit()`` their seed data before invoking the test client (the sample_* fixtures already do). """ Session = sessionmaker(bind=api_db_engine) session = Session() yield session session.close() _truncate_all(api_db_engine) @pytest.fixture(scope="session") def api_redis_cache(redis_url: str, worker_id: str): """Session-scoped real Redis backend with a per-xdist-worker prefix. Redis is mandatory infrastructure, so the shared app fixtures exercise the production backend. The key prefix mirrors ``db_schema`` naming (``hub_test_gw0``) so parallel xdist workers on one Redis instance never see each other's keys. """ parsed = urlparse(redis_url) db_path = (parsed.path or "/0").lstrip("/") return RedisCacheBackend( host=parsed.hostname or "localhost", port=parsed.port or 6379, db=int(db_path) if db_path else 0, password=parsed.password, key_prefix=f"hub_test_{worker_id}", ) @pytest.fixture(autouse=True) def _flush_redis_cache(api_redis_cache): """Flush this worker's cache keys before each test (autouse). The database is truncated between tests; the cache must be too, or module-scoped apps sharing one backend would serve stale HITs from earlier tests. ``delete("")`` SCAN-matches every key under the worker's ``hub_test_`` prefix — including keys left behind by a previous pytest run (worker prefixes are stable). """ api_redis_cache.delete("") @pytest.fixture(scope="session") def mock_mqtt(): """Session-scoped mock MQTT client (no per-test state).""" from unittest.mock import MagicMock mock = MagicMock() mock.connect.return_value = None mock.start_background.return_value = None mock.stop.return_value = None mock.disconnect.return_value = None mock.publish_command.return_value = None return mock @pytest.fixture(scope="session") def mock_db_manager(api_db_engine): """Session-scoped mock database manager backed by the shared engine.""" from unittest.mock import MagicMock manager = MagicMock(spec=DatabaseManager) Session = sessionmaker(bind=api_db_engine) manager.get_session = lambda: Session() @contextmanager def _session_scope(): session = Session() try: yield session session.commit() except Exception: session.rollback() raise finally: session.close() manager.session_scope = _session_scope return manager @pytest.fixture(autouse=True) def _isolate_db_global(monkeypatch: pytest.MonkeyPatch, mock_db_manager) -> None: """Pin ``meshcore_hub.api.app._db_manager`` to the mock for every test. Function-scoped autouse so tests that mutate the global (e.g. the lifespan tests in ``test_cache.py``) cannot leak state to siblings. ``monkeypatch`` restores the prior value on exit. """ import meshcore_hub.api.app as app_module monkeypatch.setattr(app_module, "_db_manager", mock_db_manager) def _wire_overrides( app, api_db_engine, mock_mqtt, mock_db_manager, redis_cache ) -> None: """Install the standard DB/MQTT/cache wiring on ``app``. ``app.state.redis_cache`` is pinned to the real session backend because TestClient never runs the lifespan (which would normally construct it). """ Session = sessionmaker(bind=api_db_engine) def override_get_db_manager(request=None): return mock_db_manager def override_get_db_session(): session = Session() try: yield session finally: session.close() def override_get_mqtt_client(request=None): return mock_mqtt app.dependency_overrides[get_db_manager] = override_get_db_manager app.dependency_overrides[get_db_session] = override_get_db_session app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client app.state.redis_cache = redis_cache @pytest.fixture(scope="module") def app_no_auth(db_url, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache): """Module-scoped FastAPI app with no authentication. Built once per test module; ``create_app`` is the second-most expensive setup step (~0.3s), so sharing it across a module is a major win. The ``_isolate_db_global`` autouse fixture handles the global ``_db_manager`` so this fixture doesn't need a ``with patch(...)`` context. """ app = create_app( database_url=db_url, read_key=None, admin_key=None, # /metrics denies unauthenticated access by default; the no-auth # app opts in so data-shape tests keep working without a key. metrics_public=True, ) _wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache) yield app @pytest.fixture(scope="module") def app_with_auth(db_url, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache): """Module-scoped FastAPI app with authentication enabled.""" app = create_app( database_url=db_url, read_key="test-read-key", admin_key="test-admin-key", ) _wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache) yield app @pytest.fixture(scope="module") def app_spam(db_url, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache): """Module-scoped app with spam detection enabled (no auth).""" app = create_app( database_url=db_url, read_key=None, admin_key=None, spam_detection_enabled=True, spam_score_threshold=0.6, ) _wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager, api_redis_cache) yield app @pytest.fixture def client_spam(app_spam, api_redis_cache) -> TestClient: """Test client with spam detection enabled.""" # Re-pin the real backend: tests that swap a mock/_FakeCache onto the # shared module-scoped app's state must not displace it permanently. app_spam.state.redis_cache = api_redis_cache return TestClient(app_spam, raise_server_exceptions=True) @pytest.fixture def client_no_auth(app_no_auth, api_redis_cache) -> TestClient: """Test client with no authentication.""" # Re-pin the real backend (see client_spam). app_no_auth.state.redis_cache = api_redis_cache return TestClient(app_no_auth, raise_server_exceptions=True) @pytest.fixture def client_with_auth(app_with_auth, api_redis_cache) -> TestClient: """Test client with authentication enabled.""" # Re-pin the real backend (see client_spam). app_with_auth.state.redis_cache = api_redis_cache return TestClient(app_with_auth, raise_server_exceptions=True) @pytest.fixture def sample_node(api_db_session): """Create a sample node in the database.""" node = Node( public_key="abc123def456abc123def456abc123de", name="Test Node", adv_type="REPEATER", first_seen=datetime.now(timezone.utc), last_seen=datetime.now(timezone.utc), ) api_db_session.add(node) api_db_session.commit() api_db_session.refresh(node) return node @pytest.fixture def sample_node_tag(api_db_session, sample_node): """Create a sample node tag in the database.""" tag = NodeTag( node_id=sample_node.id, key="environment", value="production", ) api_db_session.add(tag) api_db_session.commit() api_db_session.refresh(tag) return tag @pytest.fixture def sample_message(api_db_session): """Create a sample message in the database.""" message = Message( message_type="direct", pubkey_prefix="abc123", text="Hello World", received_at=datetime.now(timezone.utc), ) api_db_session.add(message) api_db_session.commit() api_db_session.refresh(message) return message @pytest.fixture def sample_advertisement(api_db_session): """Create a sample advertisement in the database.""" advert = Advertisement( public_key="abc123def456abc123def456abc123de", name="TestNode", adv_type="REPEATER", received_at=datetime.now(timezone.utc), ) api_db_session.add(advert) api_db_session.commit() api_db_session.refresh(advert) return advert @pytest.fixture def sample_telemetry(api_db_session): """Create a sample telemetry record in the database.""" telemetry = Telemetry( node_public_key="abc123def456abc123def456abc123de", parsed_data={ "battery_level": 85.5, "temperature": 25.3, }, received_at=datetime.now(timezone.utc), ) api_db_session.add(telemetry) api_db_session.commit() api_db_session.refresh(telemetry) return telemetry @pytest.fixture def sample_trace_path(api_db_session): """Create a sample trace path in the database.""" trace = TracePath( initiator_tag=12345, path_hashes=["abc123", "def456", "ghi789"], hop_count=3, received_at=datetime.now(timezone.utc), ) api_db_session.add(trace) api_db_session.commit() api_db_session.refresh(trace) return trace @pytest.fixture def receiver_node(api_db_session): """Create a receiver node in the database.""" node = Node( public_key="receiver123receiver123receiver12", name="Receiver Node", adv_type="REPEATER", first_seen=datetime.now(timezone.utc), last_seen=datetime.now(timezone.utc), ) api_db_session.add(node) api_db_session.commit() api_db_session.refresh(node) return node @pytest.fixture def sample_message_with_receiver(api_db_session, receiver_node): """Create a message with a receiver node.""" event_hash = "deadbeefdeadbeefdeadbeefdeadbe01" message = Message( message_type="channel", channel_idx=17, pubkey_prefix="xyz789", text="Channel message with receiver", received_at=datetime.now(timezone.utc), observer_node_id=receiver_node.id, event_hash=event_hash, ) api_db_session.add(message) api_db_session.commit() api_db_session.refresh(message) api_db_session.add( EventObserver( event_type="message", event_hash=event_hash, observer_node_id=receiver_node.id, observed_at=datetime.now(timezone.utc), ) ) api_db_session.commit() return message @pytest.fixture def sample_advertisement_with_receiver(api_db_session, sample_node, receiver_node): """Create an advertisement with source and receiver nodes.""" event_hash = "deadbeefdeadbeefdeadbeefdeadbe02" advert = Advertisement( public_key=sample_node.public_key, name="SourceNode", adv_type="REPEATER", received_at=datetime.now(timezone.utc), node_id=sample_node.id, observer_node_id=receiver_node.id, event_hash=event_hash, ) api_db_session.add(advert) api_db_session.commit() api_db_session.refresh(advert) api_db_session.add( EventObserver( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, observed_at=datetime.now(timezone.utc), ) ) api_db_session.commit() return advert @pytest.fixture def sample_telemetry_with_receiver(api_db_session, receiver_node): """Create a telemetry record with a receiver node.""" event_hash = "deadbeefdeadbeefdeadbeefdeadbe03" telemetry = Telemetry( node_public_key="xyz789xyz789xyz789xyz789xyz789xy", parsed_data={"battery_level": 50.0}, received_at=datetime.now(timezone.utc), observer_node_id=receiver_node.id, event_hash=event_hash, ) api_db_session.add(telemetry) api_db_session.commit() api_db_session.refresh(telemetry) api_db_session.add( EventObserver( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, observed_at=datetime.now(timezone.utc), ) ) api_db_session.commit() return telemetry @pytest.fixture def sample_trace_path_with_receiver(api_db_session, receiver_node): """Create a trace path with a receiver node.""" event_hash = "deadbeefdeadbeefdeadbeefdeadbe04" trace = TracePath( initiator_tag=99999, path_hashes=["aaa111", "bbb222"], hop_count=2, received_at=datetime.now(timezone.utc), observer_node_id=receiver_node.id, event_hash=event_hash, ) api_db_session.add(trace) api_db_session.commit() api_db_session.refresh(trace) api_db_session.add( EventObserver( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, observed_at=datetime.now(timezone.utc), ) ) api_db_session.commit() return trace @pytest.fixture def sample_node_with_name_tag(api_db_session): """Create a node with a name tag for search testing.""" node = Node( public_key="searchable123searchable123searc", name="Original Name", adv_type="CLIENT", first_seen=datetime.now(timezone.utc), ) api_db_session.add(node) api_db_session.commit() tag = NodeTag( node_id=node.id, key="name", value="Friendly Search Name", ) api_db_session.add(tag) api_db_session.commit() api_db_session.refresh(node) return node @pytest.fixture def sample_user_profile(api_db_session): """Create a sample user profile in the database.""" profile = UserProfile( user_id="oidc-user-123", name="Test User", callsign="W1TEST", ) api_db_session.add(profile) api_db_session.commit() api_db_session.refresh(profile) return profile @pytest.fixture def sample_operator_profile(api_db_session): """Create a sample operator user profile for tag editing tests.""" profile = UserProfile( user_id="operator-123", name="Test Operator", ) api_db_session.add(profile) api_db_session.commit() api_db_session.refresh(profile) return profile @pytest.fixture def sample_operator_adoption(api_db_session, sample_operator_profile, sample_node): """Create an adoption record linking operator to the sample node.""" association = UserProfileNode( user_profile_id=sample_operator_profile.id, node_id=sample_node.id, ) api_db_session.add(association) api_db_session.commit() api_db_session.refresh(association) return association @pytest.fixture def sample_adopted_node(api_db_session, sample_user_profile, sample_node): """Create a sample adopted node association.""" association = UserProfileNode( user_profile_id=sample_user_profile.id, node_id=sample_node.id, ) api_db_session.add(association) api_db_session.commit() api_db_session.refresh(association) return association @pytest.fixture def sample_channel(api_db_session): """Create a sample community channel in the database.""" channel = Channel( name="TestChannel", key_hex="AABBCCDDEEFF00112233445566778899", channel_hash=Channel.compute_channel_hash("AABBCCDDEEFF00112233445566778899"), visibility="community", enabled=True, ) api_db_session.add(channel) api_db_session.commit() api_db_session.refresh(channel) return channel @pytest.fixture def sample_member_channel(api_db_session): """Create a sample member-only channel in the database.""" key = "11223344556677889900AABBCCDDEEFF" channel = Channel( name="MemberChannel", key_hex=key, channel_hash=Channel.compute_channel_hash(key), visibility="member", enabled=True, ) api_db_session.add(channel) api_db_session.commit() api_db_session.refresh(channel) return channel @pytest.fixture def sample_admin_channel(api_db_session): """Create a sample admin-only channel in the database.""" key = "FFEEDDCCBBAA99887766554433221100" channel = Channel( name="AdminChannel", key_hex=key, channel_hash=Channel.compute_channel_hash(key), visibility="admin", enabled=True, ) api_db_session.add(channel) api_db_session.commit() api_db_session.refresh(channel) return channel