US01-02: Establish Application and Database Foundation (#47)
This commit was merged in pull request #47.
This commit is contained in:
163
tests/integration/test_app_lifecycle.py
Normal file
163
tests/integration/test_app_lifecycle.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user