feat(setup): one-call setup scaffold + onboarding flag (phase 2)

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).
This commit is contained in:
2026-06-08 15:22:04 +03:00
parent 0409cd8b66
commit 9dcd76d264
6 changed files with 1019 additions and 1 deletions
+68 -1
View File
@@ -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 063). `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`).
+2
View File
@@ -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"]
@@ -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"]
+299
View File
@@ -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,
)
+63
View File
@@ -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.",
)
@@ -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"