personal-lifecycle-tracker / backend / tests / integration / conftest.py
conftest.py
Raw
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