"""Tests for device CRUD routes. These tests exercise the FastAPI route handlers using dependency override to inject test stores, avoiding real hardware dependencies. """ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from ledgrab.api.routes.devices import router from ledgrab.storage.device_store import Device, DeviceStore from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.api import dependencies as deps # --------------------------------------------------------------------------- # App + fixtures (isolated from the real main app) # --------------------------------------------------------------------------- def _make_app(): """Build a minimal FastAPI app with just the devices router + overrides.""" app = FastAPI() app.include_router(router) return app @pytest.fixture def _route_db(tmp_path): from ledgrab.storage.database import Database db = Database(tmp_path / "test.db") yield db db.close() @pytest.fixture def device_store(_route_db): return DeviceStore(_route_db) @pytest.fixture def output_target_store(_route_db): return OutputTargetStore(_route_db) @pytest.fixture def processor_manager(): """A mock ProcessorManager — avoids real hardware.""" m = MagicMock(spec=ProcessorManager) m.add_device = MagicMock() m.remove_device = AsyncMock() m.update_device_info = MagicMock() m.find_device_state = MagicMock(return_value=None) m.get_all_device_health_dicts = MagicMock(return_value=[]) return m @pytest.fixture def client(device_store, output_target_store, processor_manager): app = _make_app() # Override auth to always pass from ledgrab.api.auth import verify_api_key app.dependency_overrides[verify_api_key] = lambda: "test-user" # Override stores and manager app.dependency_overrides[deps.get_device_store] = lambda: device_store app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager return TestClient(app, raise_server_exceptions=False) # --------------------------------------------------------------------------- # Helper to pre-populate a device # --------------------------------------------------------------------------- def _seed_device(store: DeviceStore, name="Test Device", led_count=100) -> Device: return store.create_device( name=name, url="http://192.168.1.100", led_count=led_count, ) # --------------------------------------------------------------------------- # LIST # --------------------------------------------------------------------------- class TestListDevices: def test_list_empty(self, client): resp = client.get("/api/v1/devices") assert resp.status_code == 200 data = resp.json() assert data["count"] == 0 assert data["devices"] == [] def test_list_with_devices(self, client, device_store): _seed_device(device_store, "Dev A") _seed_device(device_store, "Dev B") resp = client.get("/api/v1/devices") assert resp.status_code == 200 data = resp.json() assert data["count"] == 2 # --------------------------------------------------------------------------- # GET by ID # --------------------------------------------------------------------------- class TestGetDevice: def test_get_existing(self, client, device_store): d = _seed_device(device_store) resp = client.get(f"/api/v1/devices/{d.id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == d.id assert data["name"] == "Test Device" def test_get_not_found(self, client): resp = client.get("/api/v1/devices/nonexistent") assert resp.status_code == 404 # --------------------------------------------------------------------------- # UPDATE # --------------------------------------------------------------------------- class TestUpdateDevice: def test_update_name(self, client, device_store): d = _seed_device(device_store) resp = client.put( f"/api/v1/devices/{d.id}", json={"name": "Renamed"}, ) assert resp.status_code == 200 assert resp.json()["name"] == "Renamed" def test_update_led_count(self, client, device_store): d = _seed_device(device_store, led_count=100) resp = client.put( f"/api/v1/devices/{d.id}", json={"led_count": 300}, ) assert resp.status_code == 200 assert resp.json()["led_count"] == 300 def test_update_not_found(self, client): resp = client.put( "/api/v1/devices/missing_id", json={"name": "X"}, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # DELETE # --------------------------------------------------------------------------- class TestDeleteDevice: def test_delete_existing(self, client, device_store): d = _seed_device(device_store) resp = client.delete(f"/api/v1/devices/{d.id}") assert resp.status_code == 204 assert device_store.count() == 0 def test_delete_not_found(self, client): resp = client.delete("/api/v1/devices/missing_id") assert resp.status_code == 404 def test_delete_referenced_by_target_returns_409( self, client, device_store, output_target_store ): d = _seed_device(device_store) output_target_store.create_target( name="Target", target_type="led", device_id=d.id, ) resp = client.delete(f"/api/v1/devices/{d.id}") assert resp.status_code == 409 assert "referenced" in resp.json()["detail"].lower() # --------------------------------------------------------------------------- # Batch states # --------------------------------------------------------------------------- class TestBatchStates: def test_batch_states(self, client): resp = client.get("/api/v1/devices/batch/states") assert resp.status_code == 200 assert "states" in resp.json() # --------------------------------------------------------------------------- # PAIRING # --------------------------------------------------------------------------- class _PairableProviderStub: """Test double that exercises the four pair_device outcomes. Registered into the provider registry at test-time and unregistered afterwards so the global registry stays clean across tests. """ def __init__(self, outcome: str): self._outcome = outcome @property def device_type(self) -> str: return "_pair_test" @property def capabilities(self) -> set: return {"requires_pairing"} def create_client(self, config, *, deps): # pragma: no cover -- not used here raise AssertionError("Stub provider should not be asked to create a client") async def check_health(self, url, http_client, prev_health=None): # pragma: no cover raise AssertionError("Stub provider should not be asked for health") async def validate_device(self, url): # pragma: no cover return {} async def pair_device(self, url: str): from ledgrab.core.devices.led_client import PairingNotReady if self._outcome == "success": return {"_pair_test_token": "token-abc", "_pair_test_meta": 42} if self._outcome == "not_ready": raise PairingNotReady("Press the device button and try again.") if self._outcome == "invalid_url": raise ValueError("URL is missing a host") if self._outcome == "boom": raise RuntimeError("transport blew up") if self._outcome == "malformed": return "not-a-dict" # type: ignore[return-value] if self._outcome == "not_implemented": raise NotImplementedError("paired? what is paired?") raise AssertionError(f"unknown outcome: {self._outcome}") @pytest.fixture def pair_stub_registered(): """Register a test provider for the duration of one test.""" from ledgrab.core.devices import led_client as _led_client registered: dict = {} def _register(outcome: str): provider = _PairableProviderStub(outcome) _led_client.register_provider(provider) # type: ignore[arg-type] registered["type"] = provider.device_type return provider yield _register if "type" in registered: _led_client._provider_registry.pop(registered["type"], None) class TestPairDevice: def test_returns_provider_fields_on_success(self, client, pair_stub_registered): pair_stub_registered("success") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 200, resp.text assert resp.json() == {"fields": {"_pair_test_token": "token-abc", "_pair_test_meta": 42}} def test_unknown_device_type_returns_400(self, client): resp = client.post( "/api/v1/devices/pair", json={"device_type": "nonexistent_zzz", "url": "x://y"}, ) assert resp.status_code == 400 assert "unknown device type" in resp.json()["detail"].lower() def test_not_implemented_returns_400(self, client, pair_stub_registered): pair_stub_registered("not_implemented") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 400 assert "does not support pairing" in resp.json()["detail"] def test_pairing_not_ready_returns_409(self, client, pair_stub_registered): pair_stub_registered("not_ready") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 409 assert "press" in resp.json()["detail"].lower() def test_invalid_url_returns_422(self, client, pair_stub_registered): pair_stub_registered("invalid_url") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 422 assert "host" in resp.json()["detail"] def test_transport_exception_returns_502(self, client, pair_stub_registered): pair_stub_registered("boom") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 502 assert "pairing failed" in resp.json()["detail"].lower() def test_malformed_provider_result_returns_500(self, client, pair_stub_registered): pair_stub_registered("malformed") resp = client.post( "/api/v1/devices/pair", json={"device_type": "_pair_test", "url": "test://host"}, ) assert resp.status_code == 500 assert "malformed" in resp.json()["detail"].lower() def test_missing_required_fields_returns_422(self, client): resp = client.post("/api/v1/devices/pair", json={"device_type": "nanoleaf"}) assert resp.status_code == 422 class TestPairThenCreateFlow: """End-to-end coverage: pair, then persist; assert the token is encrypted at rest and decrypted in to_config(), and that the API response strips the secret. Closes the LOW gap in the pre-merge review: pair-route tests stopped at the 200 response, never carrying the returned fields through to storage to verify the round-trip and the response-strip. """ def test_pair_then_create_persists_encrypted_token(self, client, device_store): from ledgrab.api.routes.devices import _device_to_response from ledgrab.core.devices import led_client as _led_client from ledgrab.core.devices.led_client import DeviceHealth class _NanoleafLikeStub: @property def device_type(self): return "nanoleaf_like_stub" @property def capabilities(self): return {"manual_led_count", "requires_pairing"} async def pair_device(self, url): return {"nanoleaf_token": "secret-paired-token"} async def validate_device(self, url): return {"led_count": 9} def create_client(self, config, *, deps): raise AssertionError("not used") async def check_health(self, url, http_client, prev_health=None): return DeviceHealth(online=True) _led_client.register_provider(_NanoleafLikeStub()) try: # Step 1: pair via the route pair_resp = client.post( "/api/v1/devices/pair", json={"device_type": "nanoleaf_like_stub", "url": "stub://1.2.3.4"}, ) assert pair_resp.status_code == 200, pair_resp.text fields = pair_resp.json()["fields"] assert fields == {"nanoleaf_token": "secret-paired-token"} # Step 2: persist via the store (skip the route's create path # which would require a real validate_device handshake) device = device_store.create_device( name="E2E Paired", url="stub://1.2.3.4", led_count=9, device_type="nanoleaf", **fields, ) # In-memory device holds plaintext assert device.nanoleaf_token == "secret-paired-token" # to_config surfaces plaintext to the provider config = device.to_config() assert config.nanoleaf_token == "secret-paired-token" # Persisted row holds ciphertext (envelope prefix) persisted = device.to_dict() assert persisted["nanoleaf_token"].startswith("ENC:v1:") assert persisted["nanoleaf_token"] != "secret-paired-token" # API response strips the token; only a boolean flag remains resp = _device_to_response(device).model_dump() assert "nanoleaf_token" not in resp assert resp.get("nanoleaf_paired") is True finally: _led_client._provider_registry.pop("nanoleaf_like_stub", None)