Add Phase 0a black-box integration test suite

81 tests across 11 files plus a 5-module harness (load, pg, streams,
containers, payloads), covering all eleven Phase 0 goals and all 13 named
chaos-flag pathologies. Talks to the system only over HTTP, Postgres, Redis,
and the Docker CLI -- no imports of application code. RED-BASELINE.md records
the suite failing red against the empty repo before any implementation
existed; README.md documents the request-contract fields invented to fill
gaps in the spec, measurement methodology, and known-flaky tests.

This suite is a frozen contract from this point forward per CLAUDE.md.
This commit is contained in:
Movses Aslanyan
2026-09-05 08:11:59 +03:30
parent bffc42c9d3
commit ccf1783779
23 changed files with 2223 additions and 0 deletions
View File
+43
View File
@@ -0,0 +1,43 @@
"""Environment-driven configuration shared by tests and harness helpers.
Every value here can be overridden via env var; the defaults match the ports
documented in the project READMEs (gateway 8000, ledger..notifier 8001-8005,
postgres 5432, redis 6379). No test file should hardcode a port or DSN --
import it from here instead.
"""
import os
from pathlib import Path
# tests/harness/config.py -> parents[0]=harness, [1]=tests, [2]=repo root
REPO_ROOT = Path(__file__).resolve().parents[2]
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
SERVICE_URLS = {
"gateway": GATEWAY_URL,
"ledger": os.environ.get("LEDGER_URL", "http://localhost:8001"),
"fraud": os.environ.get("FRAUD_URL", "http://localhost:8002"),
"sanctions": os.environ.get("SANCTIONS_URL", "http://localhost:8003"),
"insights": os.environ.get("INSIGHTS_URL", "http://localhost:8004"),
"notifier": os.environ.get("NOTIFIER_URL", "http://localhost:8005"),
}
HTTP_SERVICES = tuple(SERVICE_URLS.keys())
POSTGRES_DSN = os.environ.get(
"POSTGRES_DSN", "postgresql://ledgerline:ledgerline@localhost:5432/ledgerline"
)
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
STREAM_NAME = os.environ.get("LEDGERLINE_STREAM", "stream:transactions")
CONSUMER_GROUPS = ("notifier", "audit")
DEFAULT_HTTP_TIMEOUT = float(os.environ.get("LEDGERLINE_HTTP_TIMEOUT", "15.0"))
SEED = int(os.environ.get("SEED", "42"))
# Assumption documented in tests/README.md under "Log-line assumptions": the
# sanctions service logs a line containing "lookup" for every watchlist check
# it performs, and every service logs a line containing "healthz" when its
# health endpoint is hit. Agent B's implementation must satisfy these for the
# 6.8/6.9/6.14 pathology tests to be meaningful.
SANCTIONS_LOOKUP_LOG_PATTERN = r"(?i)lookup"
HEALTHZ_LOG_PATTERN = r"(?i)healthz"
+104
View File
@@ -0,0 +1,104 @@
"""docker stats / docker compose logs / container lifecycle helpers.
Per phase-0a-test-suite-spec.md §7. All `docker compose` invocations run with
`cwd=REPO_ROOT` so they work regardless of the pytest invocation's own cwd.
"""
import json
import re
import subprocess
import time
from datetime import datetime
from .config import REPO_ROOT
def _run(cmd, timeout=30):
return subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout)
def _compose_ps(service):
result = _run(["docker", "compose", "ps", service, "--format", "json"])
lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
if not lines:
return None
return json.loads(lines[0])
def stop(service):
result = _run(["docker", "compose", "stop", service])
if result.returncode != 0:
raise RuntimeError(f"docker compose stop {service} failed: {result.stderr}")
def start(service):
result = _run(["docker", "compose", "start", service])
if result.returncode != 0:
raise RuntimeError(f"docker compose start {service} failed: {result.stderr}")
def wait_ready(service, timeout=30.0):
"""Poll `docker compose ps` until the container reports healthy (or, absent
a healthcheck, running). Raises TimeoutError otherwise."""
deadline = time.monotonic() + timeout
last_state = None
while time.monotonic() < deadline:
info = _compose_ps(service)
if info:
health = info.get("Health", "")
state = info.get("State", "")
last_state = (state, health)
if health == "healthy" or (health == "" and state == "running"):
return
time.sleep(1)
raise TimeoutError(f"{service} not ready within {timeout}s (last state={last_state})")
_MEM_UNITS = {
"B": 1 / (1024 * 1024),
"KIB": 1 / 1024,
"KB": 1 / 1024,
"MIB": 1,
"MB": 1,
"GIB": 1024,
"GB": 1024,
"TIB": 1024 * 1024,
"TB": 1024 * 1024,
}
def _parse_mem_to_mb(s):
m = re.match(r"([\d.]+)\s*([A-Za-z]+)", s.strip())
if not m:
raise ValueError(f"cannot parse memory string: {s!r}")
value, unit = float(m.group(1)), m.group(2).upper()
if unit not in _MEM_UNITS:
raise ValueError(f"unknown memory unit {unit!r} in {s!r}")
return value * _MEM_UNITS[unit]
def rss_mb(service):
"""Current RSS (approximated by `docker stats` MemUsage) of `service`'s container, in MB."""
info = _compose_ps(service)
if not info:
raise RuntimeError(f"container for {service!r} not found -- is the stack up?")
container_id = info.get("ID") or info.get("Id")
result = _run(["docker", "stats", container_id, "--no-stream", "--format", "{{.MemUsage}}"])
mem_str = result.stdout.strip().split("/")[0].strip()
return _parse_mem_to_mb(mem_str)
def count_log_lines(service, pattern, since):
"""Count lines in `docker compose logs <service>` since `since` matching `pattern`.
`since` is a timezone-aware datetime or a docker-compatible string (RFC3339
or a relative duration like "30s"). This is deliberately the only place
that shells out to `docker compose logs` for counting -- see
tests/README.md, "Log-counting migration", for why and what replaces it
from Phase 3 onward.
"""
since_arg = since.isoformat() if isinstance(since, datetime) else since
result = _run(
["docker", "compose", "logs", service, "--since", since_arg, "--no-color", "--no-log-prefix"]
)
regex = re.compile(pattern)
return sum(1 for line in result.stdout.splitlines() if regex.search(line))
+133
View File
@@ -0,0 +1,133 @@
"""Concurrent request driver + LatencyReport, per phase-0a-test-suite-spec.md §7.
A closed-loop driver (fixed concurrency) is used deliberately for its
simplicity here. This under-measures latency under saturation (coordinated
omission) -- Phase 6 replaces it with k6's arrival-rate executors. Don't try
to fix that in this suite; note it, live with it.
"""
import asyncio
import math
import statistics
import time
from dataclasses import dataclass, field
from typing import Callable
import httpx
@dataclass
class LatencyReport:
count: int
ok: int
http_errors: int # non-2xx HTTP responses
exceptions: int # requests that raised (timeout, connection reset, ...)
errors: int # http_errors + exceptions
error_rate: float
status_counts: dict
p50: float
p95: float
p99: float
max: float
throughput_rps: float
wall_seconds: float
latencies_ms: list = field(default_factory=list, repr=False)
def __str__(self):
return (
f"n={self.count} ok={self.ok} http_errors={self.http_errors} "
f"exceptions={self.exceptions} error_rate={self.error_rate:.1%} "
f"p50={self.p50:.1f}ms p95={self.p95:.1f}ms p99={self.p99:.1f}ms "
f"max={self.max:.1f}ms throughput={self.throughput_rps:.1f}rps "
f"wall={self.wall_seconds:.2f}s status_counts={self.status_counts}"
)
def _nearest_rank(sorted_vals, p):
"""Nearest-rank percentile: no interpolation, no averaging."""
n = len(sorted_vals)
if n == 0:
return 0.0
rank = max(1, min(n, math.ceil(p * n)))
return sorted_vals[rank - 1]
async def drive(
method: str,
url_factory: Callable[[], tuple],
total: int,
concurrency: int,
warmup: int = 20,
timeout: float = 15.0,
) -> LatencyReport:
"""Drive `total` requests at fixed `concurrency`, discarding `warmup` first.
`url_factory()` returns `(url, kwargs)`, called once per request so a test
can vary the target account/params per call.
"""
async with httpx.AsyncClient(timeout=timeout) as client:
async def one():
url, kwargs = url_factory()
t0 = time.perf_counter()
try:
resp = await client.request(method, url, **kwargs)
return (time.perf_counter() - t0) * 1000, resp.status_code
except Exception:
return (time.perf_counter() - t0) * 1000, None
sem = asyncio.Semaphore(concurrency)
async def bound(collector):
async with sem:
result = await one()
if collector is not None:
collector.append(result)
if warmup:
await asyncio.gather(*[bound(None) for _ in range(warmup)])
results: list = []
wall_start = time.perf_counter()
await asyncio.gather(*[bound(results) for _ in range(total)])
wall_seconds = time.perf_counter() - wall_start
latencies = sorted(r[0] for r in results)
status_counts: dict = {}
ok = http_errors = exceptions = 0
for _, status in results:
if status is None:
exceptions += 1
status_counts["exception"] = status_counts.get("exception", 0) + 1
elif 200 <= status < 300:
ok += 1
status_counts[status] = status_counts.get(status, 0) + 1
else:
http_errors += 1
status_counts[status] = status_counts.get(status, 0) + 1
count = len(results)
errors = http_errors + exceptions
return LatencyReport(
count=count,
ok=ok,
http_errors=http_errors,
exceptions=exceptions,
errors=errors,
error_rate=(errors / count if count else 0.0),
status_counts=status_counts,
p50=_nearest_rank(latencies, 0.50),
p95=_nearest_rank(latencies, 0.95),
p99=_nearest_rank(latencies, 0.99),
max=(latencies[-1] if latencies else 0.0),
throughput_rps=(count / wall_seconds if wall_seconds > 0 else 0.0),
wall_seconds=wall_seconds,
latencies_ms=latencies,
)
async def measure_median(n: int, fn: Callable, key=lambda x: x):
"""Run async `fn()` n times and return the run whose `key(result)` is the
median -- e.g. key=lambda r: r.p95 picks the median-p95 LatencyReport
whole, rather than averaging percentiles across runs (never do that)."""
results = [await fn() for _ in range(n)]
return sorted(results, key=key)[len(results) // 2]
+28
View File
@@ -0,0 +1,28 @@
"""The POST /api/transactions request shape this suite assumes.
Neither spec pins down the request field names for creating a transaction --
only the response envelope (phase-0-base-project-spec.md §6.1) is specified.
Since the test suite is the contract, this module fixes the shape Agent B's
implementation must accept. See tests/README.md, "Request contract
assumptions", for the rationale.
"""
def tx_payload(
account_id,
kind="deposit",
amount_minor=1000,
currency="EUR",
merchant_name="Test Merchant",
category_id=None,
):
payload = {
"account_id": account_id,
"kind": kind,
"amount_minor": amount_minor,
"currency": currency,
"merchant_name": merchant_name,
}
if category_id is not None:
payload["category_id"] = category_id
return payload
+74
View File
@@ -0,0 +1,74 @@
"""pg_stat_statements deltas and EXPLAIN helpers, per phase-0a-test-suite-spec.md §7."""
from contextlib import contextmanager
import psycopg
class _Counter:
"""Populated with `.calls` (and `.total_time_ms`) once the `with` block exits."""
def __init__(self):
self.calls = None
self.total_time_ms = None
def __repr__(self):
return f"Counter(calls={self.calls}, total_time_ms={self.total_time_ms})"
def __str__(self):
return repr(self)
@contextmanager
def statement_count(dsn):
"""Reset pg_stat_statements, run the caller's block, yield the call-count delta.
pg_stat_statements is reset globally, so this is only meaningful when
nothing else is hitting the database concurrently during the `with` block
-- true for the single-request pathology assertions this is built for.
"""
counter = _Counter()
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT pg_stat_statements_reset()")
try:
yield counter
finally:
with conn.cursor() as cur:
cur.execute(
"SELECT COALESCE(SUM(calls), 0), COALESCE(SUM(total_exec_time), 0) "
"FROM pg_stat_statements"
)
calls, total_time_ms = cur.fetchone()
counter.calls = calls
counter.total_time_ms = total_time_ms
def explain(dsn, sql, params=None):
"""Run EXPLAIN (ANALYZE, FORMAT JSON) and return the plan dict."""
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(f"EXPLAIN (ANALYZE, FORMAT JSON) {sql}", params or ())
(plan,) = cur.fetchone()
return plan[0]
def plan_node_types(plan: dict) -> set:
"""Flatten an EXPLAIN JSON plan (or its top-level {"Plan": ...} wrapper)
into the set of "Node Type" values it contains, at any depth."""
types: set = set()
root = plan.get("Plan", plan) if isinstance(plan, dict) else plan
def walk(node):
if isinstance(node, dict):
if "Node Type" in node:
types.add(node["Node Type"])
for key, value in node.items():
if key == "Plans" and isinstance(value, list):
for child in value:
walk(child)
elif isinstance(node, list):
for item in node:
walk(item)
walk(root)
return types
+28
View File
@@ -0,0 +1,28 @@
"""Redis XINFO GROUPS helpers, per phase-0a-test-suite-spec.md §7."""
import time
def group_lag(redis_client, stream: str, group: str) -> dict:
"""Return the XINFO GROUPS entry for `group` on `stream` as a dict."""
groups = redis_client.xinfo_groups(stream)
for g in groups:
if g.get("name") == group:
return g
raise ValueError(f"consumer group {group!r} not found on stream {stream!r}: {groups}")
def wait_for_drain(redis_client, stream: str, group: str, timeout: float = 15.0) -> None:
"""Block until `group`'s lag and pending count both reach 0, or raise TimeoutError."""
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
g = group_lag(redis_client, stream, group)
lag = g.get("lag") or 0
pending = g.get("pending") or 0
last = (lag, pending)
if lag == 0 and pending == 0:
return
time.sleep(0.25)
raise TimeoutError(
f"group {group!r} on {stream!r} did not drain within {timeout}s (last lag/pending={last})"
)