From f8f5f248ff60549ee2c509ca225abd7b95c20327 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 14:25:24 +0200 Subject: [PATCH] setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80) _stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite. Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/setup.py | 9 ++- src/backend/tests/test_setup_stamp.py | 81 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/backend/tests/test_setup_stamp.py diff --git a/src/backend/api/routers/setup.py b/src/backend/api/routers/setup.py index 39767ff..468d1f5 100644 --- a/src/backend/api/routers/setup.py +++ b/src/backend/api/routers/setup.py @@ -3,6 +3,7 @@ Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. NOT protected by API Key auth (standalone access). """ +import asyncio import hmac import random from datetime import datetime, timedelta @@ -312,7 +313,9 @@ async def init_db(body: PasswordBody): await _require_not_initialised() async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - _stamp_head() + # #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI; + # in un thread non c'e' un loop attivo e funziona. + await asyncio.to_thread(_stamp_head) return {"status": "ok", "message": "Database tables created successfully"} @@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody): async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) - _stamp_head() + # #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI; + # in un thread non c'e' un loop attivo e funziona. + await asyncio.to_thread(_stamp_head) return {"status": "ok", "message": "Database reset successfully"} diff --git a/src/backend/tests/test_setup_stamp.py b/src/backend/tests/test_setup_stamp.py new file mode 100644 index 0000000..fa04b11 --- /dev/null +++ b/src/backend/tests/test_setup_stamp.py @@ -0,0 +1,81 @@ +"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80). + +_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop +gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e +test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano: +qui NON si fa monkeypatch di _stamp_head. +""" +import logging.config +import sqlite3 + +import pytest +from alembic.config import Config +from alembic.script import ScriptDirectory +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from src.backend.config import settings +from src.backend.main import app + +SETUP_PWD = "test-setup-password" + + +@pytest.fixture +def file_db(tmp_path, monkeypatch): + """Punta settings, engine e session factory del router setup a un file SQLite.""" + import src.backend.api.routers.setup as setup_mod + + db_file = tmp_path / "t.db" + url = f"sqlite+aiosqlite:///{db_file.as_posix()}" + # env.py ricava sqlalchemy.url da settings.database_url (property su db_url) + monkeypatch.setattr(settings, "db_url", url) + monkeypatch.setattr(settings, "setup_password", SETUP_PWD) + file_engine = create_async_engine(url, echo=False) + monkeypatch.setattr(setup_mod, "engine", file_engine) + monkeypatch.setattr( + setup_mod, "async_session_factory", + async_sessionmaker(file_engine, expire_on_commit=False), + ) + # env.py chiama fileConfig, che disattiverebbe i logger degli altri test + monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None) + return db_file, setup_mod, file_engine + + +def _head() -> str: + from src.backend.api.routers.setup import _alembic_ini + cfg = Config(str(_alembic_ini)) + return ScriptDirectory.from_config(cfg).get_current_head() + + +def _versions(db_file) -> list[str]: + con = sqlite3.connect(db_file) + try: + return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")] + finally: + con.close() + + +@pytest.fixture +async def http(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + yield ac + + +@pytest.mark.asyncio +async def test_init_db_stamps_head(file_db, http): + db_file, _, file_engine = file_db + resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + assert _versions(db_file) == [_head()] + await file_engine.dispose() + + +@pytest.mark.asyncio +async def test_reset_db_stamps_head(file_db, http): + db_file, _, file_engine = file_db + resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200, resp.text + resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True}) + assert resp.status_code == 200, resp.text + assert _versions(db_file) == [_head()] + await file_engine.dispose()