164 lines
5.2 KiB
Python
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)
|