From 9dcd76d264b1977c67748a392370d226e73db894 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 15:22:04 +0300 Subject: [PATCH] feat(setup): one-call setup scaffold + onboarding flag (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for the first-run wizard (phase 4). - POST /api/v1/setup/scaffold: given an existing device_id + display_index (+ optional calibration), wires a working chain via the real validated store create paths — create-or-reuse capture template -> raw picture source -> picture color-strip source (calibration or default) -> LED output target -> returns the ids. Does NOT auto-start. Rolls back every entity it created (reverse order) on any partial failure, leaving no orphans; "created" events are deferred until the whole chain succeeds so a rolled-back scaffold never leaves ghost cards in the UI. - Requires an existing device_id (no inline device creation) — the wizard creates the device first via the canonical, URL-validated POST /devices, so the scaffold can't bypass device validation. display_index is bounded. - GET/PUT /api/v1/preferences/onboarding: persistent first-run flag ({onboarded, completed_at}) via db.set_setting; server stamps completed_at. - Both routes AuthRequired. Tests: 25 (scaffold happy/reuse/rollback/ validation + onboarding + calibration round-trip integration). docs/API.md. Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate phase — full build/suite gated at the final phase). --- docs/API.md | 69 ++- server/src/ledgrab/api/__init__.py | 2 + server/src/ledgrab/api/routes/preferences.py | 73 +++ server/src/ledgrab/api/routes/setup.py | 299 +++++++++++ server/src/ledgrab/api/schemas/setup.py | 63 +++ server/tests/api/routes/test_setup_routes.py | 514 +++++++++++++++++++ 6 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 server/src/ledgrab/api/routes/setup.py create mode 100644 server/src/ledgrab/api/schemas/setup.py create mode 100644 server/tests/api/routes/test_setup_routes.py diff --git a/docs/API.md b/docs/API.md index ef09f19..ca31acd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -185,7 +185,7 @@ Server configuration: MQTT broker, external URL, shutdown action, log level, ADB ## User preferences -Dashboard layout, notification settings, card display modes, and the global daylight timezone. +Dashboard layout, notification settings, card display modes, the global daylight timezone, and the first-run onboarding flag. | Method | Path | Description | | ------ | ---- | ----------- | @@ -199,6 +199,19 @@ Dashboard layout, notification settings, card display modes, and the global dayl | DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. | | GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. | | PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). | +| GET | `/api/v1/preferences/onboarding` | Read the first-run onboarding flag (`onboarded: bool`, `completed_at: str\|null`). Defaults to `false`. | +| PUT | `/api/v1/preferences/onboarding` | Persist the onboarding flag. Server auto-stamps `completed_at` when `onboarded` is set to `true` without a timestamp. | + +**Onboarding flag response shape:** + +```json +{ + "onboarded": true, + "completed_at": "2026-06-08T12:00:00.000000+00:00" +} +``` + +Defaults to `{"onboarded": false, "completed_at": null}` when never set. ## Backup, restore & server control @@ -716,6 +729,60 @@ strip-walk order defined by `(start_position, layout)`. Provide either **Idle timeout:** a session that receives no `position` calls for 60 seconds is automatically stopped and the prior target restored. +## Setup scaffold + +One-call first-run helper that creates the full capture-to-output chain and +returns all entity ids. The wizard calls this, then starts the output target +after optional calibration. + +| Method | Path | Description | +| ------ | ---- | ----------- | +| POST | `/api/v1/setup/scaffold` | Create capture template + picture source + color-strip source + LED output target in one atomic call with rollback on partial failure. Does NOT auto-start the target. | + +**Wizard sequence (Phase 4):** + +1. Discover or create the device via `POST /api/v1/devices` (full URL + normalisation + provider validation runs there). +2. Call `POST /api/v1/setup/scaffold` with the resulting `device_id`. +3. Calibrate (Phase 1 endpoints). +4. Start the output target via `POST /api/v1/output-targets/{id}/start`. + +**Request body:** + +```json +{ + "device_id": "device_abc123", + "display_index": 0, + "calibration": null +} +``` + +`device_id` is **required** and must reference an existing device (created via +`POST /api/v1/devices`). `display_index` selects the monitor to capture +(0 = primary; range 0–63). `calibration` is an optional `CalibrationConfig` +dict; when omitted, `create_default_calibration(led_count)` is used. + +**Response (201 Created):** + +```json +{ + "device_id": "device_abc123", + "capture_template_id": "tpl_11223344", + "picture_source_id": "ps_aabbccdd", + "color_strip_source_id": "css_11223344", + "output_target_id": "pt_aabbccdd", + "capture_template_reused": true +} +``` + +`capture_template_reused` is `true` when an existing template matched the +platform engine (no new template was created). + +**Rollback:** if any step fails, all entities created within the same call are +deleted in reverse order so no orphans remain. The pre-existing device and any +reused template are never deleted. Entity "created" events are emitted only +after the full chain succeeds, so a rollback never produces ghost UI cards. + ## Web UI & PWA App-level routes served by FastAPI (not under `/api/v1`). diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index 283e33c..b8c166e 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -37,6 +37,7 @@ from .routes.preferences import router as preferences_router from .routes.snapshot import router as snapshot_router from .routes.graph import router as graph_router from .routes.calibration import router as calibration_router +from .routes.setup import router as setup_router router = APIRouter() router.include_router(system_router) @@ -74,5 +75,6 @@ router.include_router(preferences_router) router.include_router(snapshot_router) router.include_router(graph_router) router.include_router(calibration_router) +router.include_router(setup_router) __all__ = ["router"] diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py index 198ab80..9ca9a19 100644 --- a/server/src/ledgrab/api/routes/preferences.py +++ b/server/src/ledgrab/api/routes/preferences.py @@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as empty/missing meaning "use system local time". """ +from datetime import datetime, timezone from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException @@ -38,6 +39,7 @@ router = APIRouter() _DASHBOARD_LAYOUT_KEY = "dashboard_layout" _NOTIFICATION_PREFS_KEY = "notification_preferences" _CARD_MODES_KEY = "card_modes" +_ONBOARDING_KEY = "onboarded" class DaylightTimezonePreference(BaseModel): @@ -285,4 +287,75 @@ async def put_daylight_timezone_preference( return DaylightTimezonePreference(timezone=saved) +# --------------------------------------------------------------------------- +# Onboarding flag +# --------------------------------------------------------------------------- + + +class OnboardingPreference(BaseModel): + """Persistent first-run onboarding flag.""" + + onboarded: bool = Field( + False, + description="True once the user has completed the first-run wizard.", + ) + completed_at: str | None = Field( + None, + description="ISO timestamp of when onboarding was first marked complete; null otherwise.", + ) + + +@router.get( + "/api/v1/preferences/onboarding", + response_model=OnboardingPreference, + tags=["Preferences"], +) +async def get_onboarding( + _: AuthRequired, + db: Database = Depends(get_database), +) -> OnboardingPreference: + """Return the first-run onboarding status. + + Defaults to ``{onboarded: false, completed_at: null}`` when the flag has + never been set. + """ + raw = db.get_setting(_ONBOARDING_KEY) + if not raw: + return OnboardingPreference() + try: + return OnboardingPreference.model_validate(raw) + except Exception as exc: + logger.warning("Stored onboarding preference invalid (%s); using default", exc) + return OnboardingPreference() + + +@router.put( + "/api/v1/preferences/onboarding", + response_model=OnboardingPreference, + tags=["Preferences"], +) +async def put_onboarding( + _: AuthRequired, + body: OnboardingPreference, + db: Database = Depends(get_database), +) -> OnboardingPreference: + """Persist the onboarding flag. + + When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided, + the server stamps the current UTC time automatically. + When ``onboarded`` is ``false``, ``completed_at`` is cleared. + """ + if body.onboarded and body.completed_at is None: + body = OnboardingPreference( + onboarded=True, + completed_at=datetime.now(timezone.utc).isoformat(), + ) + elif not body.onboarded: + body = OnboardingPreference(onboarded=False, completed_at=None) + + db.set_setting(_ONBOARDING_KEY, body.model_dump()) + logger.info("Onboarding flag updated: onboarded=%s", body.onboarded) + return body + + __all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"] diff --git a/server/src/ledgrab/api/routes/setup.py b/server/src/ledgrab/api/routes/setup.py new file mode 100644 index 0000000..0e3a947 --- /dev/null +++ b/server/src/ledgrab/api/routes/setup.py @@ -0,0 +1,299 @@ +"""Setup scaffold endpoint. + +Wires a complete capture → color-strip → output chain in one call, with +automatic rollback if any step fails so no orphan entities are left behind. + +POST /api/v1/setup/scaffold + Body: ScaffoldRequest — device_id (required, must already exist), + display_index, optional calibration dict. + Returns: ScaffoldResponse — ids of every created/reused entity. + Fires ``entity_changed`` events for every entity created in this call, + but ONLY after the full chain succeeds (no mid-chain events). + Does NOT auto-start the target (the frontend starts it after calibration). + +Rollback contract +----------------- +Entities created during THIS request are tracked in a local list. If any +step raises, they are deleted in reverse-creation order before re-raising. +Because "created" events are deferred until after the chain completes, a +rollback never produces ghost UI cards — no event for a rolled-back entity +is ever emitted. + +The device is never part of the rollback set: scaffold requires an existing +device (created via ``POST /api/v1/devices`` which runs full validation). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import ( + fire_entity_event, + get_color_strip_store, + get_device_store, + get_output_target_store, + get_picture_source_store, + get_template_store, +) +from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse +from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration +from ledgrab.core.capture_engines.factory import EngineRegistry +from ledgrab.storage.base_store import EntityNotFoundError +from ledgrab.storage.color_strip_store import ColorStripStore +from ledgrab.storage.output_target_store import OutputTargetStore +from ledgrab.storage.picture_source_store import PictureSourceStore +from ledgrab.storage import DeviceStore +from ledgrab.storage.template_store import TemplateStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) +router = APIRouter() + +_DEFAULT_TARGET_FPS = 30 + + +# --------------------------------------------------------------------------- +# Helper: capture template +# --------------------------------------------------------------------------- + + +def _get_or_create_capture_template( + template_store: TemplateStore, + created_ids: list[tuple[str, str]], +) -> tuple[str, bool]: + """Return (template_id, reused). + + Tries to find an existing template whose engine_type matches the platform's + best available engine. Falls back to creating a fresh one. + """ + best_engine = EngineRegistry.get_best_available_engine() + if not best_engine: + raise HTTPException( + status_code=503, + detail="No capture engine available on this platform; cannot scaffold.", + ) + + # Try to reuse an existing template with the same engine + for tpl in template_store.get_all_templates(): + if tpl.engine_type == best_engine: + logger.info( + "Scaffold: reusing existing capture template %s (engine=%s)", + tpl.id, + best_engine, + ) + return tpl.id, True + + # None found — create a fresh one + engine_class = EngineRegistry.get_engine(best_engine) + default_config = engine_class.get_default_config() + try: + tpl = template_store.create_template( + name=f"Scaffold capture ({best_engine})", + engine_type=best_engine, + engine_config=default_config, + description="Auto-created by first-run scaffold", + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + created_ids.append(("capture_template", tpl.id)) + logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine) + return tpl.id, False + + +# --------------------------------------------------------------------------- +# Helper: rollback +# --------------------------------------------------------------------------- + + +def _rollback( + created_ids: list[tuple[str, str]], + *, + template_store: TemplateStore, + picture_source_store: PictureSourceStore, + css_store: ColorStripStore, + output_target_store: OutputTargetStore, +) -> None: + """Delete entities created during this call, in reverse order. + + Only entities listed in ``created_ids`` are deleted; reused/pre-existing + entities (including the device) are never touched. + """ + store_map: dict[str, Any] = { + "capture_template": template_store, + "picture_source": picture_source_store, + "color_strip_source": css_store, + "output_target": output_target_store, + } + for entity_type, entity_id in reversed(created_ids): + store = store_map.get(entity_type) + if store is None: + logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type) + continue + try: + store.delete(entity_id) + logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id) + except Exception as exc: + logger.error( + "Scaffold rollback: failed to delete %s %s — %s", + entity_type, + entity_id, + exc, + ) + + +# --------------------------------------------------------------------------- +# Route +# --------------------------------------------------------------------------- + + +@router.post( + "/api/v1/setup/scaffold", + response_model=ScaffoldResponse, + status_code=201, + tags=["Setup"], +) +async def scaffold_setup( + data: ScaffoldRequest, + _auth: AuthRequired, + device_store: DeviceStore = Depends(get_device_store), + template_store: TemplateStore = Depends(get_template_store), + picture_source_store: PictureSourceStore = Depends(get_picture_source_store), + css_store: ColorStripStore = Depends(get_color_strip_store), + output_target_store: OutputTargetStore = Depends(get_output_target_store), +) -> ScaffoldResponse: + """Create a ready-to-start LED capture chain. + + Steps (each uses the real store create method for validation and ID gen): + + 1. Look up the existing device (404 if not found). + 2. Find or create a capture template for the platform-best engine. + 3. Create a raw picture source (``display_index`` + ``capture_template_id``). + 4. Create a picture color-strip source with either the provided calibration + or ``create_default_calibration(led_count)``. + 5. Create a LED output target linking the device to the CSS. + + All created entities emit ``entity_changed`` events, but ONLY after the + full chain succeeds — events are collected and fired at the very end. + On any error the entities created so far are deleted in reverse order + (rollback), and no "created" events are emitted (no ghost UI cards). + The output target is NOT started — the frontend starts it after the + optional calibration step. + """ + created_ids: list[tuple[str, str]] = [] + # Deferred "created" events: (entity_type, entity_id) — fired only on success. + pending_events: list[tuple[str, str]] = [] + + rollback_stores = dict( + template_store=template_store, + picture_source_store=picture_source_store, + css_store=css_store, + output_target_store=output_target_store, + ) + + try: + # ── Step 1: resolve existing device ───────────────────────────────── + try: + device = device_store.get(data.device_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}") + device_id = device.id + led_count = device.led_count + + # ── Step 2: capture template ───────────────────────────────────────── + capture_template_id, template_reused = _get_or_create_capture_template( + template_store, created_ids + ) + if not template_reused: + pending_events.append(("capture_template", capture_template_id)) + + # ── Step 3: picture source ─────────────────────────────────────────── + ps_name = f"Screen {data.display_index} (scaffold)" + try: + picture_source = picture_source_store.create_stream( + name=ps_name, + stream_type="raw", + display_index=data.display_index, + capture_template_id=capture_template_id, + target_fps=_DEFAULT_TARGET_FPS, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + created_ids.append(("picture_source", picture_source.id)) + pending_events.append(("picture_source", picture_source.id)) + logger.info("Scaffold: created picture source %s", picture_source.id) + + # ── Step 4: color-strip source ─────────────────────────────────────── + if data.calibration is not None: + try: + calibration = calibration_from_dict(data.calibration) + except Exception as exc: + raise HTTPException( + status_code=422, + detail=f"Invalid calibration dict: {exc}", + ) + else: + try: + calibration = create_default_calibration(led_count) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + css_name = "Screen capture (scaffold)" + try: + css = css_store.create_source( + name=css_name, + source_type="picture", + picture_source_id=picture_source.id, + calibration=calibration, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + created_ids.append(("color_strip_source", css.id)) + pending_events.append(("color_strip_source", css.id)) + logger.info("Scaffold: created color-strip source %s", css.id) + + # ── Step 5: LED output target ──────────────────────────────────────── + target_name = "LED output (scaffold)" + try: + target = output_target_store.create_wled_target( + name=target_name, + device_id=device_id, + color_strip_source_id=css.id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + created_ids.append(("output_target", target.id)) + pending_events.append(("output_target", target.id)) + logger.info("Scaffold: created output target %s", target.id) + + except HTTPException: + _rollback(created_ids, **rollback_stores) + raise + except Exception as exc: + logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True) + _rollback(created_ids, **rollback_stores) + raise HTTPException(status_code=500, detail="Internal server error during scaffold") + + # ── Full chain succeeded — fire all deferred "created" events ─────────── + for entity_type, entity_id in pending_events: + fire_entity_event(entity_type, "created", entity_id) + + logger.info( + "Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s", + device_id, + capture_template_id, + picture_source.id, + css.id, + target.id, + ) + return ScaffoldResponse( + device_id=device_id, + capture_template_id=capture_template_id, + picture_source_id=picture_source.id, + color_strip_source_id=css.id, + output_target_id=target.id, + capture_template_reused=template_reused, + ) diff --git a/server/src/ledgrab/api/schemas/setup.py b/server/src/ledgrab/api/schemas/setup.py new file mode 100644 index 0000000..e2915aa --- /dev/null +++ b/server/src/ledgrab/api/schemas/setup.py @@ -0,0 +1,63 @@ +"""Pydantic schemas for the setup scaffold endpoint.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ScaffoldRequest(BaseModel): + """Request body for ``POST /api/v1/setup/scaffold``. + + Creates a full capture-to-output chain: + capture template → picture source → picture color-strip source → LED output target + + ``device_id`` must reference an existing, validated device (created via + ``POST /api/v1/devices``). The wizard flow is: discover/create the device + via the canonical device endpoint first, then call scaffold with the + resulting ``device_id``. + """ + + # ── Existing device (required) ──────────────────────────────────────────── + device_id: str = Field( + description="ID of an existing device to wire into the chain.", + ) + + # ── Capture / picture source ────────────────────────────────────────────── + display_index: int = Field( + 0, + ge=0, + le=63, + description="Index of the monitor to capture (0 = primary; max 63).", + ) + + # ── Optional calibration override ───────────────────────────────────────── + calibration: dict[str, Any] | None = Field( + None, + description=( + "Optional CalibrationConfig dict to use for the color-strip source. " + "When omitted, ``create_default_calibration(led_count)`` is used." + ), + ) + + +class ScaffoldResponse(BaseModel): + """IDs of every entity created (or reused) by the scaffold. + + ``capture_template_reused`` is ``True`` when the scaffold matched an + existing template by engine type instead of creating a new one. + The device is always pre-existing (created via the canonical device endpoint + before calling scaffold). + """ + + device_id: str = Field(description="Device id (pre-existing).") + capture_template_id: str = Field(description="Capture template id.") + picture_source_id: str = Field(description="Raw picture source id.") + color_strip_source_id: str = Field(description="Picture color-strip source id.") + output_target_id: str = Field(description="LED output target id.") + + capture_template_reused: bool = Field( + False, + description="True when an existing matching capture template was reused.", + ) diff --git a/server/tests/api/routes/test_setup_routes.py b/server/tests/api/routes/test_setup_routes.py new file mode 100644 index 0000000..f5a51a8 --- /dev/null +++ b/server/tests/api/routes/test_setup_routes.py @@ -0,0 +1,514 @@ +"""Tests for the setup scaffold endpoint and the onboarding preference endpoints. + +Coverage: + - scaffold happy path (device_id-based; 4 entities created, correct linking ids, + entity events fired ONLY after full success) + - scaffold reuses existing capture template + - scaffold partial-failure rollback (force a later step to fail → no orphans AND + no stray "created" events emitted for the rolled-back entities) + - scaffold 404 for unknown/missing device_id + - scaffold 422 for display_index out of range (> 63) + - scaffold 422 when device_id field is absent (Pydantic validation) + - onboarding GET default (onboarded=false, completed_at=null) + - onboarding PUT round-trip (timestamps auto-stamped) + - integration: scaffold → PUT calibration on the CSS → GET CSS round-trips with it + +Deep adversarial coverage is deferred to the Phase 4 test-writer (Big Bang strategy). +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_db(tmp_path): + from ledgrab.storage.database import Database + + db = Database(tmp_path / "test_setup.db") + yield db + db.close() + + +@pytest.fixture +def device_store(tmp_db): + from ledgrab.storage import DeviceStore + + return DeviceStore(tmp_db) + + +@pytest.fixture +def template_store(tmp_db): + from ledgrab.storage.template_store import TemplateStore + + return TemplateStore(tmp_db) + + +@pytest.fixture +def picture_source_store(tmp_db): + from ledgrab.storage.picture_source_store import PictureSourceStore + + return PictureSourceStore(tmp_db) + + +@pytest.fixture +def css_store(tmp_db): + from ledgrab.storage.color_strip_store import ColorStripStore + + return ColorStripStore(tmp_db) + + +@pytest.fixture +def output_target_store(tmp_db): + from ledgrab.storage.output_target_store import OutputTargetStore + + return OutputTargetStore(tmp_db) + + +@pytest.fixture +def sample_device(device_store): + return device_store.create_device( + name="Test LED Strip", + url="http://192.168.1.10", + led_count=60, + ) + + +@pytest.fixture +def event_log(): + """Collect fire_entity_event calls for assertion.""" + log = [] + return log + + +@pytest.fixture +def setup_client( + tmp_db, + device_store, + template_store, + picture_source_store, + css_store, + output_target_store, + event_log, +): + from ledgrab.api.routes.setup import router + from ledgrab.api.auth import verify_api_key + from ledgrab.api import dependencies as deps + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[verify_api_key] = lambda: "test" + app.dependency_overrides[deps.get_device_store] = lambda: device_store + app.dependency_overrides[deps.get_template_store] = lambda: template_store + app.dependency_overrides[deps.get_picture_source_store] = lambda: picture_source_store + app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store + app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store + + # Capture entity events + def _fire(entity_type, action, entity_id): + event_log.append((entity_type, action, entity_id)) + + with patch("ledgrab.api.routes.setup.fire_entity_event", side_effect=_fire): + yield TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _scaffold(client, **overrides): + """POST a scaffold request with sensible defaults.""" + body = {"display_index": 0, **overrides} + return client.post("/api/v1/setup/scaffold", json=body) + + +# --------------------------------------------------------------------------- +# Scaffold: happy path (using existing device) +# --------------------------------------------------------------------------- + + +class TestScaffoldHappyPath: + def test_returns_201(self, setup_client, sample_device, template_store): + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 201, resp.text + + def test_response_contains_all_ids(self, setup_client, sample_device): + resp = _scaffold(setup_client, device_id=sample_device.id) + data = resp.json() + assert data["device_id"] == sample_device.id + assert data["capture_template_id"].startswith("tpl_") + assert data["picture_source_id"].startswith("ps_") + assert data["color_strip_source_id"].startswith("css_") + assert data["output_target_id"].startswith("pt_") + + def test_response_has_no_device_created_field(self, setup_client, sample_device): + """ScaffoldResponse no longer includes device_created — devices are always pre-existing.""" + resp = _scaffold(setup_client, device_id=sample_device.id) + assert "device_created" not in resp.json() + + def test_entities_are_persisted( + self, + setup_client, + sample_device, + picture_source_store, + css_store, + output_target_store, + ): + resp = _scaffold(setup_client, device_id=sample_device.id) + data = resp.json() + # All entities retrievable from the stores + ps = picture_source_store.get(data["picture_source_id"]) + assert ps is not None + css = css_store.get_source(data["color_strip_source_id"]) + assert css is not None + ot = output_target_store.get(data["output_target_id"]) + assert ot is not None + + def test_entity_links_are_correct( + self, + setup_client, + sample_device, + picture_source_store, + css_store, + output_target_store, + ): + resp = _scaffold(setup_client, device_id=sample_device.id) + data = resp.json() + + ps = picture_source_store.get(data["picture_source_id"]) + assert ps.capture_template_id == data["capture_template_id"] + assert ps.display_index == 0 + + css = css_store.get_source(data["color_strip_source_id"]) + assert css.picture_source_id == data["picture_source_id"] + + ot = output_target_store.get(data["output_target_id"]) + assert ot.device_id == sample_device.id + assert ot.color_strip_source_id == data["color_strip_source_id"] + + def test_entity_events_fire_after_success(self, setup_client, sample_device, event_log): + """Events must be emitted for all created entities — and only after success.""" + _scaffold(setup_client, device_id=sample_device.id) + types_fired = {(et, act) for et, act, _ in event_log} + assert ("picture_source", "created") in types_fired + assert ("color_strip_source", "created") in types_fired + assert ("output_target", "created") in types_fired + # Device is pre-existing — no device "created" event expected + assert ("device", "created") not in types_fired + + def test_events_fired_only_once_per_entity(self, setup_client, sample_device, event_log): + """No duplicate events for a single scaffold call.""" + _scaffold(setup_client, device_id=sample_device.id) + ps_created = [(et, act, eid) for et, act, eid in event_log if et == "picture_source"] + css_created = [(et, act, eid) for et, act, eid in event_log if et == "color_strip_source"] + ot_created = [(et, act, eid) for et, act, eid in event_log if et == "output_target"] + assert len(ps_created) == 1 + assert len(css_created) == 1 + assert len(ot_created) == 1 + + +# --------------------------------------------------------------------------- +# Scaffold: reuse existing capture template +# --------------------------------------------------------------------------- + + +class TestScaffoldReusesTemplate: + def test_reuse_existing_template(self, setup_client, sample_device, template_store): + """TemplateStore auto-creates a 'Default' template; the scaffold must reuse it.""" + all_templates_before = template_store.get_all_templates() + assert len(all_templates_before) >= 1, "TemplateStore should auto-create one" + + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["capture_template_reused"] is True + + # No new templates created + all_templates_after = template_store.get_all_templates() + assert len(all_templates_after) == len(all_templates_before) + + +# --------------------------------------------------------------------------- +# Scaffold: validation errors +# --------------------------------------------------------------------------- + + +class TestScaffoldValidation: + def test_unknown_device_id_returns_404(self, setup_client): + resp = _scaffold(setup_client, device_id="device_doesnotexist") + assert resp.status_code == 404, resp.text + + def test_missing_device_id_returns_422(self, setup_client): + """device_id is now required — omitting it must yield 422.""" + resp = setup_client.post("/api/v1/setup/scaffold", json={"display_index": 0}) + assert resp.status_code == 422, resp.text + + def test_display_index_above_max_returns_422(self, setup_client, sample_device): + """display_index > 63 must be rejected with 422.""" + resp = _scaffold(setup_client, device_id=sample_device.id, display_index=64) + assert resp.status_code == 422, resp.text + + def test_display_index_at_max_accepted(self, setup_client, sample_device): + """display_index == 63 is at the upper bound and must be accepted.""" + resp = _scaffold(setup_client, device_id=sample_device.id, display_index=63) + assert resp.status_code == 201, resp.text + + def test_display_index_negative_returns_422(self, setup_client, sample_device): + resp = _scaffold(setup_client, device_id=sample_device.id, display_index=-1) + assert resp.status_code == 422, resp.text + + def test_custom_display_index_stored(self, setup_client, sample_device, picture_source_store): + resp = _scaffold(setup_client, device_id=sample_device.id, display_index=2) + assert resp.status_code == 201, resp.text + ps = picture_source_store.get(resp.json()["picture_source_id"]) + assert ps.display_index == 2 + + +# --------------------------------------------------------------------------- +# Scaffold: rollback on partial failure — no orphans AND no ghost events +# --------------------------------------------------------------------------- + + +class TestScaffoldRollback: + def test_no_orphans_when_css_creation_fails( + self, + setup_client, + sample_device, + picture_source_store, + css_store, + ): + """Force css_store.create_source to raise; expect the picture source to be deleted too.""" + original_create = css_store.create_source + + def _fail_create(*args, **kwargs): + raise ValueError("Simulated CSS creation failure") + + css_store.create_source = _fail_create + try: + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 400, resp.text + + # No picture sources should remain + remaining_ps = picture_source_store.get_all_streams() + assert len(remaining_ps) == 0, "Picture source should have been rolled back" + + # No CSS created either + remaining_css = css_store.get_all_sources() + assert len(remaining_css) == 0 + finally: + css_store.create_source = original_create + + def test_no_orphans_when_output_target_creation_fails( + self, + setup_client, + sample_device, + picture_source_store, + css_store, + output_target_store, + ): + """Force output_target_store.create_wled_target to raise; picture source and CSS rolled back.""" + original_create = output_target_store.create_wled_target + + def _fail_create(*args, **kwargs): + raise ValueError("Simulated output target failure") + + output_target_store.create_wled_target = _fail_create + try: + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 400, resp.text + + assert len(picture_source_store.get_all_streams()) == 0 + assert len(css_store.get_all_sources()) == 0 + assert len(output_target_store.get_all_targets()) == 0 + finally: + output_target_store.create_wled_target = original_create + + def test_no_created_events_emitted_on_rollback( + self, + setup_client, + sample_device, + css_store, + event_log, + ): + """On failure no 'created' events must leak (deferred-event contract).""" + original_create = css_store.create_source + + def _fail_create(*args, **kwargs): + raise ValueError("Simulated CSS failure") + + css_store.create_source = _fail_create + try: + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 400, resp.text + + created_events = [(et, act) for et, act, _ in event_log if act == "created"] + assert ( + created_events == [] + ), f"No 'created' events should fire on rollback, got: {created_events}" + finally: + css_store.create_source = original_create + + def test_reused_template_not_deleted_on_rollback( + self, + setup_client, + sample_device, + template_store, + css_store, + ): + """A reused (pre-existing) capture template must survive rollback.""" + templates_before = {t.id for t in template_store.get_all_templates()} + original_create_css = css_store.create_source + + def _fail_css(*args, **kwargs): + raise ValueError("Forced failure") + + css_store.create_source = _fail_css + try: + _scaffold(setup_client, device_id=sample_device.id) + finally: + css_store.create_source = original_create_css + + templates_after = {t.id for t in template_store.get_all_templates()} + assert templates_before == templates_after + + def test_device_never_deleted_on_rollback( + self, + setup_client, + sample_device, + device_store, + css_store, + ): + """The pre-existing device must never be touched by rollback.""" + original_create = css_store.create_source + + def _fail_create(*args, **kwargs): + raise ValueError("Simulated CSS failure") + + css_store.create_source = _fail_create + try: + _scaffold(setup_client, device_id=sample_device.id) + finally: + css_store.create_source = original_create + + # Device must still exist + device = device_store.get(sample_device.id) + assert device is not None + assert device.name == sample_device.name + + +# --------------------------------------------------------------------------- +# Onboarding preference +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pref_client(tmp_db): + from ledgrab.api.routes.preferences import router + from ledgrab.api.auth import verify_api_key + from ledgrab.api import dependencies as deps + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[verify_api_key] = lambda: "test" + app.dependency_overrides[deps.get_database] = lambda: tmp_db + + return TestClient(app) + + +class TestOnboarding: + def test_get_default_returns_not_onboarded(self, pref_client): + resp = pref_client.get("/api/v1/preferences/onboarding") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["onboarded"] is False + assert data["completed_at"] is None + + def test_put_onboarded_true_round_trips(self, pref_client): + resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["onboarded"] is True + # Server auto-stamps completed_at + assert data["completed_at"] is not None + + def test_get_after_put_reflects_stored_value(self, pref_client): + pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) + resp = pref_client.get("/api/v1/preferences/onboarding") + assert resp.status_code == 200 + assert resp.json()["onboarded"] is True + + def test_put_false_clears_completed_at(self, pref_client): + # First set to true + pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) + # Then reset + resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False}) + assert resp.status_code == 200 + data = resp.json() + assert data["onboarded"] is False + assert data["completed_at"] is None + + def test_put_with_explicit_completed_at_preserved(self, pref_client): + ts = "2026-01-01T00:00:00+00:00" + resp = pref_client.put( + "/api/v1/preferences/onboarding", + json={"onboarded": True, "completed_at": ts}, + ) + assert resp.status_code == 200 + assert resp.json()["completed_at"] == ts + + +# --------------------------------------------------------------------------- +# Integration: scaffold → PUT calibration onto CSS → GET CSS round-trips +# --------------------------------------------------------------------------- + + +class TestScaffoldCalibrationIntegration: + def test_scaffold_then_update_css_calibration( + self, + setup_client, + sample_device, + css_store, + ): + """Full integration path: scaffold → apply solved calibration via CSS PUT. + + Uses the same css_store fixture that the setup_client uses, so the + scaffolded entity is visible to it after creation. + """ + from ledgrab.core.capture.calibration import CalibrationConfig + + # Step 1: scaffold + resp = _scaffold(setup_client, device_id=sample_device.id) + assert resp.status_code == 201, resp.text + css_id = resp.json()["color_strip_source_id"] + + # Confirm entity exists in the shared store + css = css_store.get_source(css_id) + assert css is not None + + # Step 2: build a solved calibration (mimics Phase 1 solve output) + solved_cal = CalibrationConfig( + layout="clockwise", + start_position="bottom_left", + leds_top=15, + leds_right=9, + leds_bottom=15, + leds_left=9, + ) + + # Step 3: persist via store update (the real CSS PUT does this) + css_store.update_source(css_id, calibration=solved_cal) + + # Step 4: assert the calibration round-trips + updated = css_store.get_source(css_id) + assert updated.calibration.leds_top == 15 + assert updated.calibration.leds_right == 9 + assert updated.calibration.layout == "clockwise"