266 lines
8.1 KiB
Python
266 lines
8.1 KiB
Python
"""Integration-test fixtures using an isolated Docker Compose infrastructure.
|
|
|
|
The test infra (postgres + redis + rabbitmq + minio) is brought up automatically
|
|
by the session-scoped `infra` fixture and torn down after the session. This
|
|
keeps integration tests from racing against the development service workers
|
|
that also consume RabbitMQ queues.
|
|
|
|
To run against an already-running external infra instead, set
|
|
``INTEGRATION_TEST_USE_EXTERNAL_INFRA=1`` before invoking pytest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from asgi_lifespan import LifespanManager
|
|
from sqlalchemy import text
|
|
|
|
from contract_check.api.app import create_app
|
|
from contract_check.core.config import get_settings
|
|
from contract_check.core.db.session import create_session_factory
|
|
from contract_check.core.tokens import hash_token
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
_BOT_SERVICE_TOKEN = "it-test-bot-token"
|
|
|
|
# Ports must match docker-compose.test.yml.
|
|
_TEST_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:25432/contract_check"
|
|
_TEST_AMQP_URL = "amqp://contract_check:contract_check@localhost:6672//"
|
|
_TEST_S3_URL = "http://localhost:10000"
|
|
|
|
_DB_URL = _TEST_DB_URL
|
|
_AMQP_URL = _TEST_AMQP_URL
|
|
_S3_URL = _TEST_S3_URL
|
|
|
|
_USE_EXTERNAL_INFRA = os.environ.get("INTEGRATION_TEST_USE_EXTERNAL_INFRA", "0") == "1"
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=str(_repo_root()),
|
|
env={**os.environ},
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if check and result.returncode != 0:
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode,
|
|
cmd,
|
|
output=result.stdout,
|
|
stderr=result.stderr,
|
|
)
|
|
return result
|
|
|
|
|
|
def _docker_compose_test(args: list[str]) -> list[str]:
|
|
# Use a dedicated project name so the test stack is independent from the
|
|
# development stack. Without this, `docker compose` treats the running dev
|
|
# services as "orphans" and returns a non-zero exit code.
|
|
return [
|
|
"docker",
|
|
"compose",
|
|
"-p",
|
|
"contract-check-test",
|
|
"-f",
|
|
str(_repo_root() / "docker-compose.test.yml"),
|
|
*args,
|
|
]
|
|
|
|
|
|
def _wait_for_healthy(service: str, *, deadline_seconds: int = 60) -> None:
|
|
"""Poll `docker compose ps` until `service` reports healthy."""
|
|
start = time.monotonic()
|
|
while time.monotonic() - start < deadline_seconds:
|
|
result = _run(
|
|
_docker_compose_test(["ps", service, "--format", "json"]),
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 and result.stdout:
|
|
# `docker compose ps --format json` emits one JSON object per line.
|
|
for line in result.stdout.strip().splitlines():
|
|
try:
|
|
info = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if info.get("Health") == "healthy":
|
|
return
|
|
if info.get("State") == "exited" and info.get("ExitCode") == 0:
|
|
# One-shot containers are also fine.
|
|
return
|
|
time.sleep(1)
|
|
raise RuntimeError(f"service {service} did not become healthy within {deadline_seconds}s")
|
|
|
|
|
|
def _start_test_infra() -> None:
|
|
if _USE_EXTERNAL_INFRA:
|
|
return
|
|
|
|
# Bring up the test stack without --wait: minio-init-test is a one-shot
|
|
# container that exits after creating the bucket, which makes --wait fail.
|
|
_run(_docker_compose_test(["up", "-d"]))
|
|
|
|
# Wait for every persistent service to be healthy.
|
|
for service in ("postgres-test", "redis-test", "rabbitmq-test", "minio-test"):
|
|
_wait_for_healthy(service)
|
|
|
|
# postgres healthcheck can still race with alembic, so explicitly wait for
|
|
# the DB to accept connections.
|
|
_run(
|
|
_docker_compose_test(
|
|
[
|
|
"exec",
|
|
"-T",
|
|
"postgres-test",
|
|
"sh",
|
|
"-c",
|
|
"until pg_isready -U contract_check -d contract_check; do sleep 1; done",
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
def _stop_test_infra() -> None:
|
|
if _USE_EXTERNAL_INFRA:
|
|
return
|
|
# Use --volumes to wipe test data between runs. Never use -v for external infra.
|
|
_run(_docker_compose_test(["down", "-v"]), check=False)
|
|
|
|
|
|
def _set_env() -> None:
|
|
env_vars = {
|
|
"DATABASE_URL": _DB_URL,
|
|
"RABBITMQ_URL": _AMQP_URL,
|
|
"S3_ENDPOINT_URL": _S3_URL,
|
|
"S3_ACCESS_KEY": "contract_check",
|
|
"S3_SECRET_KEY": "contract_check",
|
|
"S3_BUCKET": "contract-check-docs-test",
|
|
"REDIS_URL": "redis://localhost:27379/0",
|
|
"OLLAMA_HOST": "http://localhost",
|
|
"OLLAMA_API_KEY": "test",
|
|
"JWT_SECRET": "it-test-jwt-secret-not-for-production",
|
|
"JWT_ALGORITHM": "HS256",
|
|
"JWT_ACCESS_TTL_MINUTES": "1440",
|
|
"TELEGRAM_BOT_TOKEN": "it-test-bot-token:it-test-secret",
|
|
# httpx ASGITransport buffers the whole response — infinite SSE streams
|
|
# can only complete via the max-stream deadline (which emits the
|
|
# `timeout` event). Keep it short so streaming tests finish fast.
|
|
"SSE_POLL_INTERVAL_SECONDS": "0.25",
|
|
"SSE_MAX_STREAM_SECONDS": "5",
|
|
}
|
|
for k, v in env_vars.items():
|
|
os.environ[k] = v
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def infra() -> Iterator[dict[str, str]]:
|
|
_set_env()
|
|
get_settings.cache_clear()
|
|
|
|
_start_test_infra()
|
|
|
|
_run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"alembic",
|
|
"-c",
|
|
str(_repo_root() / "alembic.ini"),
|
|
"upgrade",
|
|
"head",
|
|
],
|
|
check=True,
|
|
)
|
|
|
|
factory = create_session_factory()
|
|
|
|
async def _seed() -> None:
|
|
async with factory() as session:
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO service_tokens (name, token_hash, adapter) "
|
|
"VALUES (:name, :hash, 'bot') "
|
|
"ON CONFLICT (name) DO UPDATE SET "
|
|
" token_hash = EXCLUDED.token_hash, revoked = FALSE"
|
|
),
|
|
{"name": "bot-test", "hash": hash_token(_BOT_SERVICE_TOKEN)},
|
|
)
|
|
await session.commit()
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
yield {
|
|
"database_url": _DB_URL,
|
|
"rabbitmq_url": _AMQP_URL,
|
|
"s3_endpoint_url": _S3_URL,
|
|
"s3_access_key": "contract_check",
|
|
"s3_secret_key": "contract_check",
|
|
"s3_bucket": "contract-check-docs-test",
|
|
"token": _BOT_SERVICE_TOKEN,
|
|
}
|
|
|
|
get_settings.cache_clear()
|
|
_stop_test_infra()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(infra: dict[str, str]) -> httpx.AsyncClient:
|
|
get_settings.cache_clear()
|
|
_set_env()
|
|
get_settings()
|
|
app = create_app()
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as c:
|
|
async with LifespanManager(app):
|
|
yield c
|
|
get_settings.cache_clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_header(infra: dict[str, str]) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {infra['token']}"}
|
|
|
|
|
|
async def user_token(client: httpx.AsyncClient, infra: dict[str, str], telegram_id: int) -> str:
|
|
"""Call /auth/telegram/bot and return a user JWT for the given telegram_id."""
|
|
r = await client.post(
|
|
"/api/v1/auth/telegram/bot",
|
|
json={"telegram_id": telegram_id},
|
|
headers={"Authorization": f"Bearer {infra['token']}"},
|
|
)
|
|
assert r.status_code == 200, f"auth failed: {r.status_code} {r.text}"
|
|
return r.json()["access_token"]
|
|
|
|
|
|
@pytest.fixture
|
|
async def db_session():
|
|
_set_env()
|
|
get_settings.cache_clear()
|
|
factory = create_session_factory()
|
|
async with factory() as session:
|
|
yield session
|
|
await session.close()
|
|
get_settings.cache_clear()
|