From 2e4db53f6a20eb173fbc8c55345fe09e1c449671 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 17 Apr 2026 23:04:41 +0200 Subject: [PATCH] feat(setup): seed ST-DEFAULT station and assign existing recipes Add _seed_default_station() helper to /api/setup/seed endpoint that creates the ST-DEFAULT station and assigns all active recipes to it, preserving existing behaviour after migration 002 runs on live DBs. Helper is idempotent and is called on both the normal seed path and the early-return path (when demo data already exists). Co-Authored-By: Claude Sonnet 4.6 --- server/routers/setup.py | 50 +++++++++++++++ server/tests/test_station_seed.py | 100 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 server/tests/test_station_seed.py diff --git a/server/routers/setup.py b/server/routers/setup.py index 7d9e403..5ade483 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -13,12 +13,14 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sqlalchemy import inspect as sa_inspect, select +from sqlalchemy.ext.asyncio import AsyncSession from config import settings from database import Base, engine, async_session_factory from models import ( User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) +from models.station import Station, StationRecipeAssignment from services.auth_service import hash_password from services.measurement_service import calculate_pass_fail @@ -161,6 +163,50 @@ def _generate_measurements_for_subtask( return measurements +async def _seed_default_station(session: AsyncSession, admin_user: User) -> None: + """Ensure ST-DEFAULT station exists and all active recipes are assigned to it. + + Idempotent: safe to call multiple times. Skips creation if the station + already exists and only adds assignments for recipes not yet assigned. + """ + existing = await session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default_station = existing.scalar_one_or_none() + + if default_station is None: + default_station = Station( + code="ST-DEFAULT", + name="Default Station", + location="Initial seed - change me", + created_by=admin_user.id, + ) + session.add(default_station) + await session.flush() + await session.refresh(default_station) + + # Collect already-assigned recipe IDs to avoid unique-constraint violations. + existing_assignments = await session.execute( + select(StationRecipeAssignment.recipe_id).where( + StationRecipeAssignment.station_id == default_station.id + ) + ) + existing_ids = {row[0] for row in existing_assignments} + + recipes_result = await session.execute( + select(Recipe).where(Recipe.active == True) + ) + for r in recipes_result.scalars().all(): + if r.id not in existing_ids: + session.add(StationRecipeAssignment( + station_id=default_station.id, + recipe_id=r.id, + assigned_by=admin_user.id, + )) + + await session.flush() + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -299,6 +345,7 @@ async def seed_demo_data(body: PasswordBody): select(Recipe).where(Recipe.code == "DEMO-001") ) if existing_recipe.scalar_one_or_none(): + await _seed_default_station(session, admin_user) return { "status": "ok", "message": "Demo data already exists, skipped", @@ -453,6 +500,9 @@ async def seed_demo_data(body: PasswordBody): session.add_all(ms) measurements_created += len(ms) + # ---- Default Station ---------------------------------------------- + await _seed_default_station(session, admin_user) + return { "status": "ok", "message": "Demo data seeded successfully", diff --git a/server/tests/test_station_seed.py b/server/tests/test_station_seed.py new file mode 100644 index 0000000..7d8b1ac --- /dev/null +++ b/server/tests/test_station_seed.py @@ -0,0 +1,100 @@ +"""Verify /api/setup/seed creates a default station with all recipes assigned. + +DEVIATION FROM PLAN: The real setup endpoint is NOT a single /api/setup/initialize. +The setup is split into separate endpoints: + - POST /api/setup/init-db (creates tables) + - POST /api/setup/seed (seeds users + demo recipe + stations) + +The seed body is {"password": "..."} with no load_demo_data flag (demo data +is always seeded). The station seed is added inside /api/setup/seed. + +The seed endpoint uses async_session_factory directly (not get_db dependency +injection), so we must monkeypatch routers.setup.engine and +routers.setup.async_session_factory to point at the test SQLite engine, +exactly as done in test_setup.py. +""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from models.station import Station, StationRecipeAssignment +from models.recipe import Recipe +from tests.conftest import test_engine, TestSessionFactory + + +SETUP_PWD = "test-setup-pwd" + + +@pytest.fixture(autouse=True) +def enable_setup(monkeypatch): + """Enable setup endpoints and redirect engine/session to test SQLite DB.""" + monkeypatch.setattr(settings, "setup_password", SETUP_PWD) + import routers.setup as setup_mod + monkeypatch.setattr(setup_mod, "engine", test_engine) + monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) + + +async def _seed(client: AsyncClient) -> dict: + """Init DB tables then run seed; return seed response JSON.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_setup_seed_creates_default_station_and_assigns_recipes( + client: AsyncClient, + db_session: AsyncSession, +): + """After seeding, ST-DEFAULT station must exist and all active recipes assigned.""" + await _seed(client) + + # Default station must exist and be active. + result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + default = result.scalar_one_or_none() + assert default is not None, "ST-DEFAULT station was not created" + assert default.active is True + + # All active recipes must be assigned to the default station. + active_recipes_result = await db_session.execute( + select(Recipe).where(Recipe.active == True) + ) + active_recipes = active_recipes_result.scalars().all() + assert len(active_recipes) > 0, "demo seed must create at least one active recipe" + + assignments_result = await db_session.execute( + select(StationRecipeAssignment).where( + StationRecipeAssignment.station_id == default.id + ) + ) + n_assignments = len(assignments_result.scalars().all()) + assert n_assignments == len(active_recipes), ( + f"Expected {len(active_recipes)} assignment(s), got {n_assignments}" + ) + + +@pytest.mark.asyncio +async def test_setup_seed_station_idempotent( + client: AsyncClient, + db_session: AsyncSession, +): + """Running seed twice must not duplicate ST-DEFAULT or its assignments.""" + # First run — creates everything. + await _seed(client) + + # Second run — recipe DEMO-001 already exists → seed returns early. + # ST-DEFAULT must still exist (not re-created, not duplicated). + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + + stations_result = await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + ) + stations = stations_result.scalars().all() + assert len(stations) == 1, f"Expected exactly 1 ST-DEFAULT, got {len(stations)}"