from __future__ import annotations
import os
import uuid
from typing import Generator, Dict, Any
import httpx
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker, Session
def resolve_service_url(env_var: str, primary_url: str, fallback_url: str, probe_path: str = "/health") -> str:
"""
Resolves the live service URL, checking the primary container network URL first
and falling back to the localhost port if primary is unreachable.
"""
custom = os.environ.get(env_var)
if custom:
return custom
# Try primary container network URL, explicit container name, then host port fallback
candidate_urls = [
primary_url,
"http://personal-lifecycle-tracker-backend:8000" if "backend" in primary_url else primary_url,
fallback_url,
]
for url in candidate_urls:
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(f"{url}{probe_path}")
if resp.status_code < 500:
return url
except Exception:
continue
return primary_url
def resolve_db_url() -> str:
"""Resolves PostgreSQL database URL for container or host network."""
custom = os.environ.get("INTEGRATION_DB_URL") or os.environ.get("DATABASE_URL")
if custom and ("postgres" in custom):
return custom
# Primary container network URL
primary = "postgresql://postgres:postgres@db:5432/lifecycle"
try:
eng = create_engine(primary, pool_pre_ping=True)
with eng.connect() as conn:
conn.execute(text("SELECT 1"))
eng.dispose()
return primary
except Exception:
pass
# Fallback host network URL
fallback = "postgresql://postgres:postgres@localhost:5434/lifecycle"
return fallback
@pytest.fixture(scope="session")
def live_backend_url() -> str:
return resolve_service_url("INTEGRATION_BACKEND_URL", "http://backend:8000", "http://localhost:8003", "/health")
@pytest.fixture(scope="session")
def live_frontend_url() -> str:
return resolve_service_url("INTEGRATION_FRONTEND_URL", "http://frontend:3000", "http://localhost:3003", "/")
@pytest.fixture(scope="session")
def live_db_url() -> str:
return resolve_db_url()
@pytest.fixture(scope="session", autouse=True)
def verify_services_healthy(live_backend_url: str):
"""
Ensures backend service is alive before running any integration tests.
Skips the integration suite cleanly if docker-compose services are not running.
"""
last_err = None
for _ in range(5):
try:
with httpx.Client(timeout=5.0) as client:
res = client.get(f"{live_backend_url}/health")
if res.status_code == 200:
return
last_err = f"Backend returned status {res.status_code}"
except Exception as exc:
last_err = str(exc)
import time
time.sleep(1)
pytest.skip(
f"Cannot connect to backend service at {live_backend_url} ({last_err}). "
"Integration tests require running docker-compose containers (run 'make up' or 'docker compose up -d')."
)
@pytest.fixture(autouse=True)
def reset_store_state():
"""
Override parent conftest autouse fixture so in-process SQLite store
is not needlessly reseeded on each live container integration test.
"""
yield
@pytest.fixture(scope="session")
def db_engine(live_db_url: str) -> Generator[Engine, None, None]:
engine = create_engine(live_db_url, pool_pre_ping=True)
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine: Engine) -> Generator[Session, None, None]:
session_factory = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
session = session_factory()
try:
yield session
finally:
session.close()
@pytest.fixture
def api_client(live_backend_url: str) -> Generator[httpx.Client, None, None]:
with httpx.Client(base_url=live_backend_url, timeout=15.0) as client:
yield client
@pytest.fixture
def frontend_client(live_frontend_url: str) -> Generator[httpx.Client, None, None]:
with httpx.Client(base_url=live_frontend_url, timeout=15.0) as client:
yield client
@pytest.fixture(scope="session")
def reset_live_db(live_backend_url: str):
"""Resets the live database to initial seed state."""
with httpx.Client(base_url=live_backend_url, timeout=10.0) as client:
res = client.post("/api/v1/dev/seed")
assert res.status_code == 200, f"Failed to seed live database: {res.text}"
@pytest.fixture
def alex_auth_headers(api_client: httpx.Client) -> Dict[str, str]:
res = api_client.post("/api/v1/auth/login", json={"email": "alex@example.com", "password": "password123"})
assert res.status_code == 200, f"Alex login failed: {res.text}"
token = res.json()["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def sarah_auth_headers(api_client: httpx.Client) -> Dict[str, str]:
res = api_client.post("/api/v1/auth/login", json={"email": "sarah@example.com", "password": "password123"})
assert res.status_code == 200, f"Sarah login failed: {res.text}"
token = res.json()["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def jordan_auth_headers(api_client: httpx.Client) -> Dict[str, str]:
res = api_client.post("/api/v1/auth/login", json={"email": "jordan@example.com", "password": "password123"})
assert res.status_code == 200, f"Jordan login failed: {res.text}"
token = res.json()["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def create_test_user(api_client: httpx.Client):
"""Factory creating an isolated user for test isolation."""
created_user_ids = []
def _factory(email_prefix: str = "itest", name: str = "Integration Test User") -> Dict[str, Any]:
random_suffix = uuid.uuid4().hex[:8]
email = f"{email_prefix}_{random_suffix}@example.com"
password = "Password123!"
res = api_client.post(
"/api/v1/auth/signup",
json={"email": email, "password": password, "name": f"{name} {random_suffix}"},
)
assert res.status_code == 201, f"Failed to create test user: {res.text}"
data = res.json()
user_id = data["user"]["id"]
created_user_ids.append(user_id)
token = data["token"]
return {
"id": user_id,
"email": email,
"password": password,
"name": data["user"]["name"],
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
}
yield _factory
# Teardown: delete created test users
for uid in created_user_ids:
try:
# Login as Alex (or cleanup directly)
alex_res = api_client.post("/api/v1/auth/login", json={"email": "alex@example.com", "password": "password123"})
if alex_res.status_code == 200:
alex_headers = {"Authorization": f"Bearer {alex_res.json()['token']}"}
api_client.delete(f"/api/v1/users/{uid}", headers=alex_headers)
except Exception:
pass