"""Application lifecycle: readiness, WAL, foreign keys, restart, clean shutdown. In-process tests drive the real ASGI app (its lifespan runs migrations, opens the engine, and disposes it). One process-level test launches ``python -m photo_pipeline serve`` as a real child process and waits on readiness, matching production boundaries. """ import os import socket import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path import httpx import pytest from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.exc import IntegrityError from photo_pipeline.api.app import create_app from photo_pipeline.config import Config from photo_pipeline.models import Asset, AssetPath REPO = Path(__file__).resolve().parents[2] @pytest.fixture def config(tmp_path): return Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path)}) def test_live_and_ready(config): app = create_app(config) with TestClient(app) as client: assert client.get("/api/v1/health/live").json() == {"status": "alive"} ready = client.get("/api/v1/health/ready") assert ready.status_code == 200 body = ready.json() assert body["status"] == "ready" assert str(body["checks"]["journal_mode"]).lower() == "wal" assert body["checks"]["foreign_keys"] == 1 def test_ready_reports_wal_and_foreign_keys_on_engine(config): app = create_app(config) with TestClient(app): with app.state.engine.connect() as conn: assert str(conn.execute(text("PRAGMA journal_mode")).scalar()).lower() == "wal" assert int(conn.execute(text("PRAGMA foreign_keys")).scalar()) == 1 def test_foreign_keys_are_enforced(config): app = create_app(config) with TestClient(app): session = app.state.session_factory() try: session.add( AssetPath( asset_id="does-not-exist", path="ghost.jpg", valid_from=datetime.now(timezone.utc), ) ) with pytest.raises(IntegrityError): session.commit() finally: session.close() def test_restart_preserves_data_and_reruns_migrations(config): app1 = create_app(config) with TestClient(app1): session = app1.state.session_factory() try: session.add( Asset( id="asset-1", original_path="one.jpg", current_path="one.jpg", hash_version=1, discovered_at=datetime.now(timezone.utc), ) ) session.commit() finally: session.close() # Fresh app against the same data dir: migrations are idempotent, data survives. app2 = create_app(config) with TestClient(app2) as client: assert client.get("/api/v1/health/ready").status_code == 200 session = app2.state.session_factory() try: asset = session.get(Asset, "asset-1") assert asset is not None assert asset.current_path == "one.jpg" finally: session.close() def test_shutdown_disposes_engine(config): app = create_app(config) with TestClient(app): engine = app.state.engine assert engine is not None # Lifespan shutdown ran: state cleared and no connections left checked out. assert app.state.engine is None assert engine.pool.checkedout() == 0 def _free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] def test_process_level_readiness_and_clean_shutdown(tmp_path): port = _free_port() env = { **os.environ, "PHOTO_PIPELINE_DATA_DIR": str(tmp_path), "PHOTO_PIPELINE_HOST": "127.0.0.1", "PHOTO_PIPELINE_PORT": str(port), } proc = subprocess.Popen( [sys.executable, "-m", "photo_pipeline", "serve"], cwd=str(REPO), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: base = f"http://127.0.0.1:{port}/api/v1/health/ready" deadline = time.monotonic() + 30 body = None while time.monotonic() < deadline: if proc.poll() is not None: out, err = proc.communicate() pytest.fail(f"server exited early: {err.decode(errors='replace')}") try: response = httpx.get(base, timeout=1.0) if response.status_code == 200: body = response.json() break except httpx.HTTPError: time.sleep(0.2) assert body is not None, "server never became ready" assert body["status"] == "ready" assert str(body["checks"]["journal_mode"]).lower() == "wal" assert body["checks"]["foreign_keys"] == 1 finally: proc.terminate() try: proc.wait(timeout=15) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=5) pytest.fail("server did not shut down cleanly on SIGTERM") # Clean shutdown: terminated by our signal, not a crash. assert proc.returncode in (0, -15)