Files
photoanalyzer/tests/integration/test_app_lifecycle.py

164 lines
5.2 KiB
Python

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