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