From 242add047bdf4f014cc00bde1df729eeb7a6e6a1 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:07:45 +0200 Subject: [PATCH] Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/config.py | 19 +++- src/backend/database.py | 33 ++++-- src/backend/tests/test_sqlite_file.py | 144 ++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 src/backend/tests/test_sqlite_file.py diff --git a/src/backend/config.py b/src/backend/config.py index ae3f84c..7633f41 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -27,6 +27,10 @@ class Settings(BaseSettings): db_name: str = "tiemeasureflow" db_user: str = "tmflow" db_password: str = _DEFAULT_DB_PASSWORD + # Full SQLAlchemy async URL (env DB_URL). When set it wins over the + # DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows + # package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes). + db_url: str | None = None # Presentation time zone (DB is UTC, #42) display_timezone: str = "Europe/Rome" @@ -78,7 +82,9 @@ class Settings(BaseSettings): @property def database_url(self) -> str: - """Async MySQL connection string.""" + """Async connection string: DB_URL if set, else the MySQL one.""" + if self.db_url: + return self.db_url return ( f"mysql+asyncmy://{self.db_user}:{self.db_password}" f"@{self.db_host}:{self.db_port}/{self.db_name}" @@ -97,7 +103,12 @@ class Settings(BaseSettings): (mounted as a Docker volume), not inside the backend tree. """ # Path(__file__) = src/backend/config.py → parents[2] = project root - return Path(__file__).resolve().parents[2] / self.upload_dir + # UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...): + # in that case it is used as is instead of being joined to the root. + configured = Path(self.upload_dir) + if configured.is_absolute(): + return configured + return Path(__file__).resolve().parents[2] / configured # Always resolve .env against the project root regardless of cwd # (pydantic-settings would otherwise treat the path as cwd-relative). @@ -122,6 +133,10 @@ class Settings(BaseSettings): "SERVER_SECRET_KEY is still the committed default value; " "set it before running with APP_ENV=" + self.app_env ) + # A SQLite file has no DB password: the default-password guard only + # makes sense for the MySQL connection built from the DB_* parts. + if self.db_url and self.db_url.startswith("sqlite"): + return self if self.db_password == _DEFAULT_DB_PASSWORD: raise RuntimeError( "DB_PASSWORD is still the committed default value; " diff --git a/src/backend/database.py b/src/backend/database.py index 7b57fb0..dd3fb6d 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1,6 +1,7 @@ """Async SQLAlchemy database engine and session management.""" from collections.abc import AsyncGenerator +from sqlalchemy import event from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase from src.backend.config import settings -# Create async engine -engine = create_async_engine( - settings.database_url, - echo=False, - pool_size=10, - max_overflow=20, - pool_recycle=3600, -) +_IS_SQLITE = settings.database_url.startswith("sqlite") + +# Create async engine. MySQL gets a connection pool; SQLite (local file of the +# Windows package) does not accept pool_size/max_overflow/pool_recycle. +if _IS_SQLITE: + engine = create_async_engine(settings.database_url, echo=False) + + @event.listens_for(engine.sync_engine, "connect") + def _sqlite_pragmas(dbapi_connection, _connection_record) -> None: + """Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL + + busy_timeout let the web, vision and backend threads share the file.""" + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() +else: + engine = create_async_engine( + settings.database_url, + echo=False, + pool_size=10, + max_overflow=20, + pool_recycle=3600, + ) # Session factory async_session_factory = async_sessionmaker( diff --git a/src/backend/tests/test_sqlite_file.py b/src/backend/tests/test_sqlite_file.py new file mode 100644 index 0000000..a036e1c --- /dev/null +++ b/src/backend/tests/test_sqlite_file.py @@ -0,0 +1,144 @@ +"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77). + +Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi +i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR +impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare +l'engine in memoria usato dal resto della suite. +""" +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +from src.backend.config import Settings + +_ROOT = Path(__file__).resolve().parents[3] + +# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale +# (health, login con utente creato via ORM), PRAGMA e vincolo FK. +_SCRIPT = textwrap.dedent( + """ + import asyncio, json, sys + from pathlib import Path + from unittest.mock import MagicMock + # WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B) + sys.modules.setdefault("weasyprint", MagicMock()) + from alembic import command + from alembic.config import Config + + from src.backend.config import settings + ini = Path(r"{ini}") + command.upgrade(Config(str(ini)), "head") + + from httpx import ASGITransport, AsyncClient + from sqlalchemy import select, text + from sqlalchemy.exc import IntegrityError + from src.backend.database import async_session_factory, engine + from src.backend.main import app + from src.backend.models.orm.user import User + from src.backend.models.orm.recipe import RecipeVersion + from src.backend.services.auth_service import hash_password + + async def main(): + out = {{}} + async with async_session_factory() as s: + s.add(User(username="fiera", password_hash=hash_password("Pass12345"), + display_name="Fiera", roles=["Maker"], is_admin=True)) + await s.commit() + async with async_session_factory() as s: + out["utente"] = (await s.execute(select(User.username))).scalars().all() + out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar() + out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar() + out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar() + # FK attive: una versione con recipe_id inesistente deve essere rifiutata + try: + async with async_session_factory() as s: + s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1)) + await s.commit() + out["fk_violation"] = False + except IntegrityError: + out["fk_violation"] = True + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + out["health"] = (await c.get("/api/health")).status_code + r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}}) + out["login"] = r.status_code + out["upload_path"] = str(settings.upload_path) + out["database_url"] = settings.database_url + await engine.dispose() + print("RISULTATO=" + json.dumps(out)) + + asyncio.run(main()) + """ +) + + +def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict: + ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini" + env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")} + env.update(extra_env) + env["PYTHONPATH"] = str(_ROOT) + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.run( + [sys.executable, "-c", _SCRIPT.format(ini=str(ini))], + cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180, + ) + assert proc.returncode == 0, proc.stderr[-3000:] + line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1] + return json.loads(line[len("RISULTATO="):]) + + +def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path): + db = tmp_path / "tmf.db" + uploads = tmp_path / "uploads" + # barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape) + url = f"sqlite+aiosqlite:///{db.as_posix()}" + out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)}) + + assert out["database_url"] == url + assert db.exists() # il file e' stato creato da upgrade head + assert out["utente"] == ["fiera"] + assert out["health"] == 200 + assert out["login"] == 200 + assert out["fk"] == 1 # PRAGMA foreign_keys attivo + assert out["fk_violation"] is True + assert out["journal"] == "wal" + assert out["busy"] == 5000 + # UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto) + assert Path(out["upload_path"]) == uploads + + +def test_database_url_senza_db_url_resta_mysql(monkeypatch): + monkeypatch.delenv("DB_URL", raising=False) + s = Settings(_env_file=None, db_url=None) + assert s.database_url.startswith("mysql+asyncmy://") + + +def test_db_url_vince_sulle_parti_db(monkeypatch): + s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro") + assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db" + + +def test_produzione_con_sqlite_non_richiede_db_password(): + # niente RuntimeError: con SQLite la password DB di default non conta + Settings( + _env_file=None, app_env="production", server_secret_key="x" * 40, + db_url="sqlite+aiosqlite:///C:/x/tmf.db", + ) + + +def test_produzione_mysql_rifiuta_ancora_password_di_default(): + import pytest + with pytest.raises(Exception, match="DB_PASSWORD"): + Settings(_env_file=None, app_env="production", server_secret_key="x" * 40) + + +def test_upload_dir_relativo_resta_sotto_la_radice(): + s = Settings(_env_file=None, upload_dir="uploads") + assert s.upload_path == _ROOT / "uploads" + + +def test_upload_dir_assoluto(tmp_path): + s = Settings(_env_file=None, upload_dir=str(tmp_path / "u")) + assert s.upload_path == tmp_path / "u"