test(e2e): add the Compose smoke test of the running web process

Why:
- ADR-0016 reserves Compose for a serialized smoke test of the running web
  process. Nothing else exercises the deployment steps, a real HTTP server, or
  the real logging configuration, which every in-process test no-ops.

Changes:
- scripts/smoke.sh brings up Compose, runs both bootstrap steps, provisions a
  throwaway tenant, starts uvicorn, and drives the test against it
- the test skips unless SMOKE_BASE_URL is set, so `uv run pytest` never invokes
  Compose; it asserts the ADR-0011 JSON log sink

Impact:
- a pre-release gate, not a per-PR one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ali Zarinkolah
2026-08-20 22:26:47 +03:30
parent 1b873e5a6f
commit 7c1fe79f1c
2 changed files with 241 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
"""Operational smoke test against the *running web process* (ADR-0016).
Different in kind from every other test in this repo. Everything else drives
the app in-process through `ASGITransport`; this drives a real uvicorn process
over a real socket, backed by the Docker Compose stack, after the two
deployment steps (`alembic upgrade head`, `src.cli.qdrant_bootstrap`) have run
for real. ADR-0016 reserves Compose for exactly this: "manual local validation
and a later, serialized operational smoke test of the running web process",
run "as a serialized pre-release or scheduled gate".
It is therefore **skipped unless `SMOKE_BASE_URL` is set**, so a plain
`uv run pytest` never touches Docker Compose. Run it through `scripts/smoke.sh`,
which brings the stack up, provisions a tenant, starts the process, and exports
the environment below.
Because the app runs in its own process, this is also the only place in the
suite where the real `configure_logging()` is in effect --
`tests/conftest.py::_no_real_logging_configuration` no-ops it everywhere else
to keep global structlog state out of other tests. So the ADR-0011 log sink is
asserted here and nowhere else.
"""
import json
import os
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
import pytest_asyncio
from httpx import AsyncClient
from qdrant_client import AsyncQdrantClient, models
BASE_URL = os.environ.get("SMOKE_BASE_URL")
API_KEY = os.environ.get("SMOKE_API_KEY", "")
DOMAIN = os.environ.get("SMOKE_DOMAIN", "smoke")
LOG_PATH = os.environ.get("SMOKE_LOG_PATH", "")
QDRANT_URL = os.environ.get("SMOKE_QDRANT_URL", "http://127.0.0.1:6343")
QDRANT_COLLECTION = os.environ.get("SMOKE_QDRANT_COLLECTION", "chunks")
pytestmark = [
pytest.mark.e2e,
pytest.mark.slow,
pytest.mark.asyncio,
pytest.mark.skipif(
not BASE_URL,
reason="SMOKE_BASE_URL is unset; run this through scripts/smoke.sh",
),
# Well past the global 10s budget: this drives a real process over a
# socket, and the upload does real embedding work end to end.
pytest.mark.timeout(120),
]
# Unique per run so a re-run against the same persistent Compose volumes is a
# new file rather than an idempotent duplicate-hash hit (ADR-0017).
_CSV = f"question,answer\nsmoke run {uuid.uuid4().hex},indexed\n".encode()
@pytest_asyncio.fixture
async def client() -> AsyncIterator[AsyncClient]:
async with AsyncClient(base_url=BASE_URL or "", timeout=120.0) as async_client:
yield async_client
async def test_running_process_reports_healthy_and_ready() -> None:
"""Readiness must be green *after* the deployment steps, not before --
an unbootstrapped Qdrant is deliberately unready.
"""
async with AsyncClient(base_url=BASE_URL or "", timeout=30.0) as client:
healthz = await client.get("/healthz")
readyz = await client.get("/readyz")
assert healthz.status_code == 200
assert healthz.json() == {"status": "ok"}
assert readyz.status_code == 200, readyz.text
assert readyz.json() == {"postgres": True, "minio": True, "qdrant": True}
async def test_upload_through_the_running_process_indexes_retrievable_points(
client: AsyncClient,
) -> None:
"""The whole slice against the deployed shape: HTTP in, Qdrant points out."""
upload = await client.post(
"/v1/files",
files={"file": ("smoke.csv", _CSV, "text/csv")},
data={"domain": DOMAIN},
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert upload.status_code == 201, upload.text
body = upload.json()
assert body["status"] == "succeeded"
assert body["chunks_indexed"] > 0
status = await client.get(
f"/v1/files/{body['file_id']}", headers={"Authorization": f"Bearer {API_KEY}"}
)
assert status.status_code == 200
assert status.json()["ingestion_status"] == "succeeded"
qdrant = AsyncQdrantClient(url=QDRANT_URL)
try:
count = await qdrant.count(
collection_name=QDRANT_COLLECTION,
count_filter=models.Filter(
must=[
models.FieldCondition(
key="file_id", match=models.MatchValue(value=body["file_id"])
),
models.FieldCondition(key="is_active", match=models.MatchValue(value=True)),
]
),
exact=True,
)
finally:
await qdrant.close()
assert count.count == body["chunks_indexed"]
async def test_running_process_writes_structured_ingestion_logs() -> None:
"""The real ADR-0011 sink, which only a separate process exercises.
An upload that succeeds while emitting nothing parseable is an upload no
operator can investigate -- and the runbook's failure procedure is written
against these exact event names.
"""
if not LOG_PATH:
pytest.skip("SMOKE_LOG_PATH is unset; scripts/smoke.sh normally provides it")
lines = Path(LOG_PATH).read_text("utf-8").splitlines()
events = [json.loads(line) for line in lines if line.startswith("{")]
by_name = {event.get("event") for event in events}
assert "ingestion.job.started" in by_name
assert "ingestion.job.completed" in by_name
completed = next(event for event in events if event.get("event") == "ingestion.job.completed")
assert completed["tenant_id"]
assert completed["ingestion_job_id"]
assert completed["points_upserted"] > 0
# Static process context bound once at startup (ADR-0011).
assert completed["env"]
assert completed["service_version"]