Files
ledgrab/server/tests/api/routes/test_devices_routes.py
T
alexei.dolgolyov 2f31680823 feat(devices): pairing-UX scaffold (Phase 2)
Lays the groundwork for device families that require a one-time
physical pairing action (Nanoleaf hold-power-button, Tuya local-key
extraction, Twinkly network-setup mode, Hue link-button). No driver
uses it yet -- Nanoleaf will be the first concrete consumer.

Phase 2 as originally written had three bullets; only this one was
genuinely missing work. The other two (generic NetworkDiscoveryService
fan-out, unified scan-network UI) were already solved at the route
level by the existing /api/v1/devices/discover handler running all
providers in parallel via asyncio.gather(return_exceptions=True).
Marked WONTDO in TODO.md with rationale.

Backend:
- LEDDeviceProvider gains an async pair_device(url) -> dict method.
  Default raises NotImplementedError so missing implementations on a
  requires_pairing provider fail loud at request time.
- New PairingNotReady exception, distinct from generic errors so the
  route handler can return 409 (user must perform the physical action,
  retry possible) instead of 500.
- POST /api/v1/devices/pair endpoint with PairDeviceRequest /
  PairDeviceResponse schemas. Status-code mapping:
    200 -> paired, fields returned for the subsequent create payload
    400 -> unknown device type, or type doesn't support pairing
    409 -> PairingNotReady (retryable from the UI)
    422 -> invalid URL / device configuration (ValueError)
    502 -> transport / network failure (other exceptions)
    500 -> provider returned a non-dict (defensive)
- 8 route tests register a stub provider and exercise every
  status-code path.

Frontend:
- New modals/pair-device.html with five state blocks (idle / pairing
  / not_ready / success / failed) toggled via data-pair-state, plus
  a 30-second SVG progress ring with monospace countdown.
- New features/pairing-flow.ts exposing
  runPairingFlow({deviceType, url, instructionsKey?}) ->
  Promise<{fields: Record<string, unknown>>. Wires the modal to the
  pair endpoint, maps response codes to UI states, AbortControllers
  in-flight fetches on cancel. Exports a PairingCancelled sentinel
  error class.
- Generic pairing.* i18n keys in en/ru/zh. Drivers will add their own
  device.<type>.pair.instructions key that overrides the default.

Design decisions (per frontend-design skill):
- Single SVG ring + centered countdown (HomeKit-style)
- Instructions stay visible during pairing, dimmed to 60% via :has()
- Success state held 450 ms before auto-dismiss
- Cancel-X in the footer; primary action lives in the state block
- prefers-reduced-motion disables pulse/fade/ring transitions

Note: the components.css diff includes a pre-existing MiniSelect block
from the user's parallel work; pairing-specific styles are the second
hunk (lines ~1628+).
2026-05-16 03:26:53 +03:00

343 lines
11 KiB
Python

"""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