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
+12
View File
@@ -0,0 +1,12 @@
[pytest]
addopts = --strict-markers
asyncio_mode = auto
timeout = 300
testpaths = tests
markers =
contract: fast functional behaviour under chaos-clean (< 60s total)
seed: seed data integrity checks (< 20s total)
baseline: the clean system is genuinely healthy (< 90s total)
pathology: one differential test per pathology (5-12 min each)
slow: additional marker on the soak-ish pathology tests (unbounded_cache, cold_cache)
guard: "no observability yet" assertions -- delete at Phase 2 (< 5s total)
+6
View File
@@ -0,0 +1,6 @@
pytest>=8.0,<9
pytest-asyncio>=0.23,<1
pytest-timeout>=2.3,<3
httpx>=0.27,<1
psycopg[binary]>=3.1,<4
redis>=5.0,<6
+200
View File
@@ -0,0 +1,200 @@
# Ledgerline integration suite
Black-box, per `docs/phase-0a-test-suite-spec.md`. Talks to the system only
over HTTP, Postgres, Redis, and the Docker CLI -- no imports from `services/`
or `common/`. Once Phase 0b is green, treat this directory as a **read-only
contract**: no test, threshold, marker, or fixture gets edited to make code
pass. If a test looks wrong or impossible, stop and say so.
## Running it
Prerequisite for every marker: `make up && make seed`.
```bash
make test # everything except slow (the default CI-shaped run)
make test-contract # contract + seed + guard, < 60s
make test-baseline # baseline, < 90s
make test-pathology # pathology and not slow, 5-12 min
make test-slow # slow (unbounded_cache, cold_cache), several minutes
make test-all # no marker filter
```
> **Phase 8 spoiler warning** (see the root `CLAUDE.md`): `test-pathology` and
> `test-slow` are a complete answer key to which chaos flags do what. Don't
> run them during the Phase 8 bottleneck hunt.
Config is env-driven with Compose defaults as fallback -- see
`tests/harness/config.py` for the full list (`GATEWAY_URL`, `LEDGER_URL`, ...,
`POSTGRES_DSN`, `REDIS_URL`, `SEED`). Override any of them to point the suite
at a non-default stack.
## Request contract assumptions
Neither spec pins down the JSON body for `POST /api/transactions` -- only the
response envelope. Since this suite *is* the contract until Phase 0b exists,
`tests/harness/payloads.py::tx_payload()` fixes it:
```json
{
"account_id": "<uuid>",
"kind": "deposit" | "withdrawal" | "purchase",
"amount_minor": <int>,
"currency": "EUR" | "USD" | "GBP",
"merchant_name": "<string>",
"category_id": <smallint, optional>
}
```
Agent B's implementation must accept exactly this shape. If it turns out to
be wrong or incomplete once real requirements surface, that's a suite change
to negotiate explicitly -- not something to route around silently.
## Log-line assumptions
Three pathology tests (6.8, 6.9, 6.14) count container log lines, per
`phase-0a-test-suite-spec.md`'s note that this is the only cross-service call
counter available at Phase 0. They assume, and Agent B's plain-text logging
must satisfy:
- `sanctions` logs a line containing the word "lookup" (case-insensitive) for
every watchlist check it performs.
- Every downstream service logs a line containing "healthz" (case-insensitive)
when its health endpoint is hit.
See `tests/harness/config.py` for the exact patterns
(`SANCTIONS_LOOKUP_LOG_PATTERN`, `HEALTHZ_LOG_PATTERN`).
## Measurement methodology
- **Ratios, not absolutes.** With one deliberate exception (G7's p95 < 400ms,
which the spec itself calls out as "the one absolute threshold in the
functional set"), every assertion compares flag-on against flag-off in the
same run, on the same machine. Absolute millisecond thresholds don't survive
a different laptop; ratios mostly do.
- **Percentiles**: nearest-rank on the raw sorted sample, no interpolation.
Never average percentiles across runs -- `harness.load.measure_median`
picks the whole run whose key metric (e.g. p95) is the median, so you get a
consistent report, not a Frankenstein average.
- **Prefer signals that are hard to fake.** `pg_stat_statements` call counts
(6.3, and the live-reconfig check in `test_contract_chaos.py`), `EXPLAIN`
plan node types (6.4), Redis consumer-group lag (6.11, and G7/G3),
container RSS (6.10), and a genuine unhandled-exception path (6.13) can't be
satisfied by a `sleep()`. Where both a hard signal and a latency ratio are
available, both are asserted.
- **Warm-up requests are discarded** before timing starts, per `harness.load.drive`.
## Coordinated omission
`harness/load.py::drive` is a closed-loop driver: fixed concurrency, next
request issued only after a slot frees up. This is simple but
**under-measures latency under saturation** -- a slow response delays the
next request's start rather than being counted as more queueing time. Phase 6
replaces this with k6's arrival-rate (open-model) executors. This suite
doesn't try to fix it; the ratios it asserts are generous enough to tolerate
the distortion, but don't mistake the numbers here for a real load-testing
tool's output.
## Known-flaky tests
**6.10 (`unbounded_cache`) and 6.12 (`cold_cache`)** are the least stable in
the suite -- both marked `slow` and excluded from `make test`/`test-pathology`:
- 6.10 depends on `docker stats` RSS sampling being reasonably quiet on the
host (no other memory-hungry process competing) across a 30s window twice.
A genuine failure looks like *no* growth difference between baseline and
degraded (the leak isn't happening); noise looks like both numbers being a
few MB higher than expected but the *relative* gap still holding.
- 6.12 depends on exactly two TTL expiries landing inside a 150s window at
1rps against a 60s TTL. A genuine failure is zero spikes in the degraded
run; noise is a spike count off by one in either direction from a slow
request unrelated to cache expiry. Re-run before concluding the mechanism
is broken.
**`test_clean_baseline_is_healthy` (G7), `test_nightmare_degrades_but_survives`
(G8), and `test_blocking_event_loop_serializes_concurrent_requests`'s baseline
assertion (6.7)** are sensitive to host load discovered empirically during
Phase 0b implementation, not just theorized:
- All three compare a "fast path" latency (mostly cache hits, or a single
request) against a "slow path" one measured moments later, under real
concurrent load on whatever machine is running the suite. The gap between
them is genuine, but its exact ratio swings with momentary CPU/scheduler
contention -- a background process, a thermal-throttled laptop, or simply
another test having just finished can shift a run from comfortably passing
to just short.
- This is the same risk `test_clean_baseline_is_healthy`'s own docstring
already names ("if this fails on a slow machine, that machine cannot run
the pathology suite meaningfully either") -- empirical testing during
Phase 0b confirmed it also reaches G8 and 6.7's concurrency-ratio
assertions specifically, at roughly a 60-80% pass rate on ordinary
development hardware (one 2024-era laptop, Docker Desktop/OrbStack, no
other heavy load). Re-run before concluding a genuine regression; a
consistent, repeatable failure (not just an occasional miss) is the signal
worth investigating.
- `test_pool_starvation_plateaus_throughput` (6.5) was **removed** during
Phase 0b for a related but distinct reason: it wasn't flaky, it failed
consistently, on its baseline (no chaos flags) assertion that throughput
scales >= 2.5x from concurrency=5 to concurrency=25. Isolated reproduction
(a bare `GET /healthz` with zero backend work, measured from inside the
gateway container, bypassing Docker's networking entirely) showed the same
flat-to-negative scaling regardless of any chaos flag or connection pool
size. Root cause: `phase-0-base-project-spec.md` §3 mandates uvicorn
running a single worker per service -- there is no second process or core
for one service to spread load across, so throughput cannot scale with
added concurrency in this deployment, independent of pool_starvation. The
pool_starvation pathology itself (§7) is still implemented in full
(`services/ledger/db.py`'s dual-engine pool selection); only this one
differential test, whose premise conflicted with the spec's own mandated
architecture, was removed.
## Seed determinism
G4 requires "re-running the seed with the same `SEED` yields an identical
manifest." A full re-seed takes up to 60s (per the base spec's own budget),
which would blow the `seed` marker's <20s budget and the combined
`test-contract` <60s budget on every single run, and it's destructive to
whatever other tests' fixture data is live at the time. `test_seed_data.py`
therefore only asserts that the manifest records the `SEED` value it was
generated with (`test_manifest_records_the_configured_seed`) as a fast proxy.
**Verify true determinism manually, occasionally, not as part of CI:**
```bash
make reset && cp db/seed_manifest.json /tmp/manifest-1.json
make reset && diff /tmp/manifest-1.json db/seed_manifest.json
```
## Log-counting migration (Phase 3/4)
6.8, 6.9, and 6.14 count `docker compose logs` lines via
`harness/containers.py::count_log_lines` -- deliberately the only place that
shells out for this, so there's exactly one place to change. From **Phase 3**,
rewrite these against structured logs via LogQL. From **Phase 4**, rewrite
them again against span counts (a TraceQL query for client spans within a
trace answers "how many lookups did this transaction make?" directly). Keep
all three generations' diffs if you can -- they're the clearest demonstration
of what each signal is for, on a problem you'll already understand cold.
## `test_no_observability.py` is deleted at Phase 2
It guards the Phase 0 "before" state (no OTel, no `prometheus_client`, no
structured logging, no `/metrics`). Once instrumentation exists, every
assertion in it becomes actively wrong on purpose. Delete the file; don't
maintain it.
## Goal-to-test traceability
| Goal | Description | Test file(s) |
|---|---|---|
| G1 | End-to-end transaction | `test_contract_transactions.py`, `test_contract_insights.py` |
| G2 | Really seven services | `test_contract_health.py` |
| G3 | Async work, two consumer groups | `test_contract_async.py` |
| G4 | Seed data integrity | `test_seed_data.py` |
| G5 | Chaos control, live reconfig | `test_contract_chaos.py` |
| G6 | Per-pathology differential (§6.1-6.14) | `test_pathologies.py` |
| G7 | Clean baseline is healthy | `test_baseline_health.py` |
| G8 | Nightmare degrades, doesn't collapse | `test_nightmare.py` |
| G9 | Failure containment | `test_contract_health.py` (sanctions stop/timeout paths) |
| G10 | No observability yet | `test_no_observability.py` (deleted at Phase 2) |
| G11 | Frontend served | `test_contract_frontend.py` |
Every test's docstring names its goal ID (and, for G6, its §6.x item number).
+62
View File
@@ -0,0 +1,62 @@
# RED-BASELINE
Recorded by running the suite, as written, against the actual state of the
repo at the end of Phase 0a: no `services/`, `common/`, `db/`, `scripts/`, or
`docker-compose.yml` exist yet, and no containers are running. This is the
required proof that nothing in the suite passes vacuously.
Commands run (Python 3.12 venv, `pip install -r requirements-test.txt`):
```
pytest --collect-only -q # 82 tests collected, 0 errors
pytest -m "contract or seed or guard" -q # 64 errors, 18 deselected
pytest -m "pathology or slow or baseline" -q # 18 errors, 64 deselected
```
## Why every test currently fails
**All 82 tests fail at fixture setup with the same root cause: connection
refused.** The session-scoped, autouse `stack_up` fixture in `conftest.py`
polls `GET /healthz` on all six HTTP services for 30s, gets nothing back
(there is no gateway, no ledger, no fraud, no sanctions, no insights, no
notifier -- none of `services/` exists), and calls `pytest.fail()` with:
```
Stack is not up: fraud, gateway, insights, ledger, notifier, sanctions did not
answer /healthz within 30s. Run `make up && make seed` first.
```
Every other test in the suite depends on `stack_up` (directly, or
transitively through `api`, `chaos`, `manifest` -> `seeded`, or `dsn` used
against a nonexistent Postgres). pytest caches the session-scoped failure, so
after the first 30s timeout every remaining test errors immediately at setup
with the same message, rather than re-polling.
This is deliberately the *first* thing every test checks. It is a stronger
and more honest RED signal than a wall of individual connection-refused
tracebacks from `httpx`/`psycopg`/`redis`, and it's exactly the failure mode a
human would hit running this suite manually against nothing.
## The one test that would fail on its own merits even with a live stack
`tests/test_no_observability.py::test_application_code_exists` is a sanity
guard, not a real assertion about the system: it exists specifically so
`test_no_file_imports_observability_libraries` (which walks `services/` and
`common/` looking for forbidden imports) can't pass vacuously by finding zero
`.py` files. Right now it fails at the `stack_up` setup step like everything
else, but if `stack_up` is bypassed (e.g. by testing this file's guard logic
in isolation against the current empty repo), it fails on its own assertion:
```
no .py files found under services/ or common/ -- either the app hasn't been
implemented yet, or the guard tests below would pass vacuously
```
That confirms the vacuous-pass guard itself is red for the right reason too.
## What "green" requires
`make up && make seed` need `docker-compose.yml`, `db/schema.sql`,
`scripts/seed.py`, and all seven `services/*` -- i.e. all of Phase 0b, built
from `docs/phase-0-base-project-spec.md` against this suite as a read-only
contract.
+149
View File
@@ -0,0 +1,149 @@
"""Shared fixtures for the Ledgerline black-box integration suite.
Per phase-0a-test-suite-spec.md §8. Every environment value (ports, DSN, Redis
URL) comes from harness.config, itself env-driven with the Compose defaults
as fallbacks -- no hardcoded values in test bodies.
"""
import time
from dataclasses import dataclass
import httpx
import psycopg
import pytest
import pytest_asyncio
import redis as redis_lib
from harness.config import GATEWAY_URL, HTTP_SERVICES, POSTGRES_DSN, REDIS_URL, SERVICE_URLS
@pytest.fixture(scope="session")
def dsn():
return POSTGRES_DSN
@pytest.fixture(scope="session")
def redis_client():
client = redis_lib.from_url(REDIS_URL, decode_responses=True)
yield client
client.close()
@pytest.fixture(scope="session", autouse=True)
def stack_up():
"""Fail fast, with a clear message, if the Compose stack isn't up and ready.
Every test in this suite implicitly depends on this: none of them are
meaningful against a partially-started stack, and none of them should hang
waiting for services that were simply never started.
"""
deadline = time.monotonic() + 30
unhealthy = dict(SERVICE_URLS)
with httpx.Client(timeout=5.0) as client:
while unhealthy and time.monotonic() < deadline:
for name, url in list(unhealthy.items()):
try:
resp = client.get(f"{url}/healthz")
if resp.status_code == 200:
del unhealthy[name]
except httpx.HTTPError:
pass
if unhealthy:
time.sleep(1)
if unhealthy:
pytest.fail(
"Stack is not up: " + ", ".join(sorted(unhealthy)) +
" did not answer /healthz within 30s. Run `make up && make seed` first.",
pytrace=False,
)
@pytest.fixture(scope="session")
def seeded(dsn):
"""Fail fast if the seed manifest is missing or empty."""
try:
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT data FROM seed_manifest LIMIT 1")
row = cur.fetchone()
except Exception as exc:
pytest.fail(f"Seed manifest not readable ({exc}). Run `make seed` first.", pytrace=False)
if not row or not row[0] or not row[0].get("counts", {}).get("transactions"):
pytest.fail("Seed manifest is empty or has zero transactions. Run `make seed` first.", pytrace=False)
@pytest.fixture(scope="session")
def manifest(seeded, dsn):
"""The parsed seed manifest (db/seed_manifest.json via the seed_manifest table)."""
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT data FROM seed_manifest LIMIT 1")
(data,) = cur.fetchone()
return data
@pytest_asyncio.fixture
async def api():
"""httpx.AsyncClient against the gateway, 15s timeout."""
async with httpx.AsyncClient(base_url=GATEWAY_URL, timeout=15.0) as client:
yield client
@dataclass
class ChaosControl:
client: httpx.AsyncClient
async def get(self):
resp = await self.client.get("/api/admin/chaos")
resp.raise_for_status()
return resp.json()
async def set_flag(self, flag, enabled):
return await self.client.post("/api/admin/chaos", json={"flag": flag, "enabled": enabled})
async def set_profile(self, profile):
resp = await self.client.post("/api/admin/chaos/profile", json={"profile": profile})
resp.raise_for_status()
return resp.json()
@pytest_asyncio.fixture
async def chaos(api):
"""chaos.set_profile(name), chaos.set_flag(name, bool), chaos.get().
Teardown always resets the profile to `clean`, even if the test failed --
design principle #6 in phase-0a-test-suite-spec.md §2: every test that
enables a chaos flag must restore state.
"""
control = ChaosControl(api)
try:
yield control
finally:
await control.set_profile("clean")
@pytest.fixture
def fresh_balances(dsn):
"""fresh_balances(account_id) snapshots a balance now and restores it on
teardown, so balance assertions don't drift across repeated runs."""
tracked = {}
def track(account_id):
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT balance_minor FROM accounts WHERE id = %s", (account_id,))
row = cur.fetchone()
if row is None:
raise ValueError(f"unknown account {account_id}")
tracked[account_id] = row[0]
return row[0]
yield track
if tracked:
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
for account_id, balance in tracked.items():
cur.execute(
"UPDATE accounts SET balance_minor = %s WHERE id = %s",
(balance, account_id),
)
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})"
)
+68
View File
@@ -0,0 +1,68 @@
"""G7 -- the clean baseline is actually healthy; otherwise G6 means nothing."""
import random
import httpx
import pytest
from harness.config import GATEWAY_URL, POSTGRES_DSN, STREAM_NAME
from harness.load import drive
from harness.payloads import tx_payload
from harness.pg import statement_count
from harness.streams import wait_for_drain
pytestmark = pytest.mark.baseline
async def test_clean_baseline_is_healthy(chaos, manifest):
"""G7: with chaos-clean, after 20 discarded warm-up requests, 200 requests
at concurrency 10 against random cold accounts show zero errors, p95 <
400ms (the one absolute threshold in the functional set -- if this fails on
a slow machine, that machine cannot run the pathology suite meaningfully
either), and p99/p50 < 5 (no fat tail)."""
await chaos.set_profile("clean")
cold_accounts = manifest["cold_account_ids_sample"]
def url_factory():
account_id = random.choice(cold_accounts)
return (f"{GATEWAY_URL}/api/accounts/{account_id}/insights", {})
report = await drive("GET", url_factory, total=200, concurrency=10, warmup=20)
assert report.error_rate == 0, (
f"clean baseline must have zero errors -- got {report}. If this fails "
f"on a slow machine, that machine cannot run the pathology suite "
f"meaningfully either."
)
assert report.p95 < 400, str(report)
assert report.p99 / report.p50 < 5, str(report)
async def test_clean_insights_issues_few_statements(chaos, manifest):
"""G7: insights for a cold account issues fewer than 10 SQL statements."""
await chaos.set_profile("clean")
account_id = manifest["cold_account_ids_sample"][0]
with statement_count(POSTGRES_DSN) as counter:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights")
assert resp.status_code == 200
assert counter.calls < 10, f"insights issued {counter.calls} statements: {counter}"
async def test_clean_redis_consumer_lag_drains_quickly(chaos, manifest, redis_client, fresh_balances):
"""G7: Redis consumer lag returns to 0 within 15s of a burst finishing."""
await chaos.set_profile("clean")
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
async with httpx.AsyncClient(timeout=15) as client:
for _ in range(20):
resp = await client.post(
f"{GATEWAY_URL}/api/transactions",
json=tx_payload(account_id, amount_minor=100, merchant_name="Baseline Burst"),
)
assert resp.status_code == 201
for group in ("notifier", "audit"):
wait_for_drain(redis_client, STREAM_NAME, group, timeout=15)
+68
View File
@@ -0,0 +1,68 @@
"""G3 -- async work happens after the response, via two independent consumer groups."""
import time
import psycopg
import pytest
from harness.config import STREAM_NAME
from harness.payloads import tx_payload
pytestmark = pytest.mark.contract
async def test_two_independent_consumer_groups_exist(redis_client):
"""G3: XINFO GROUPS reports exactly two groups, `notifier` and `audit`."""
groups = redis_client.xinfo_groups(STREAM_NAME)
names = {g["name"] for g in groups}
assert names == {"notifier", "audit"}, groups
async def test_notification_and_audit_rows_appear(api, manifest, fresh_balances, dsn):
"""G3: after a successful transaction, rows appear in both `notifications`
and `audit_events` for that transaction_id within 10s -- proving two
independent consumer groups, not one consumer doing both jobs."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
resp = await api.post("/api/transactions", json=tx_payload(account_id, merchant_name="Async Test"))
assert resp.status_code == 201
tx_id = resp.json()["transaction_id"]
deadline = time.monotonic() + 10
found_notification = found_audit = False
with psycopg.connect(dsn, autocommit=True) as conn:
while time.monotonic() < deadline and not (found_notification and found_audit):
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM notifications WHERE transaction_id = %s", (tx_id,))
found_notification = cur.fetchone() is not None
cur.execute("SELECT 1 FROM audit_events WHERE transaction_id = %s", (tx_id,))
found_audit = cur.fetchone() is not None
if not (found_notification and found_audit):
time.sleep(0.5)
assert found_notification, f"no notifications row for {tx_id} within 10s"
assert found_audit, f"no audit_events row for {tx_id} within 10s"
async def test_response_returns_before_notification_exists_at_least_once(api, manifest, fresh_balances, dsn):
"""G3: across 20 attempts, at least once the API response returns before the
notification row exists -- proving the work is genuinely off the request
path. Tolerant of a fast machine winning the race sometimes."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
saw_a_miss = False
with psycopg.connect(dsn, autocommit=True) as conn:
for _ in range(20):
resp = await api.post(
"/api/transactions", json=tx_payload(account_id, amount_minor=100, merchant_name="Race Test")
)
assert resp.status_code == 201
tx_id = resp.json()["transaction_id"]
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM notifications WHERE transaction_id = %s", (tx_id,))
if cur.fetchone() is None:
saw_a_miss = True
break
assert saw_a_miss, "notification row existed immediately on every one of 20 attempts"
+78
View File
@@ -0,0 +1,78 @@
"""G5 -- chaos flags toggle at runtime, without a restart."""
import pytest
from harness.config import POSTGRES_DSN
from harness.pg import statement_count
pytestmark = pytest.mark.contract
ALL_FLAGS = {
"slow_sanctions", "sanctions_errors", "n_plus_one", "pool_starvation",
"row_lock_contention", "blocking_event_loop", "retry_storm", "no_batching",
"unbounded_cache", "slow_consumer", "cold_cache", "poison_data", "chatty_health",
}
async def test_chaos_lists_all_thirteen_flags(chaos):
"""G5: GET /api/admin/chaos lists all thirteen flags by name."""
state = await chaos.get()
assert set(state["flags"]) == ALL_FLAGS, state["flags"]
async def test_toggling_one_flag_changes_only_that_flag(chaos):
"""G5: toggling a single flag changes only that flag."""
before = (await chaos.get())["flags"]
await chaos.set_flag("slow_sanctions", True)
after = (await chaos.get())["flags"]
assert after["slow_sanctions"] is True
for flag in ALL_FLAGS - {"slow_sanctions"}:
assert after[flag] == before[flag], f"{flag} changed unexpectedly"
async def test_profile_clean_sets_all_false(chaos):
"""G5: the `clean` profile sets every flag to false."""
state = await chaos.set_profile("clean")
assert all(v is False for v in state["flags"].values()), state["flags"]
async def test_profile_nightmare_sets_all_true(chaos):
"""G5: the `nightmare` profile sets every flag to true."""
state = await chaos.set_profile("nightmare")
assert all(v is True for v in state["flags"].values()), state["flags"]
async def test_profile_light_sets_exactly_two_flags(chaos):
"""G5: the `light` profile sets exactly slow_sanctions and cold_cache."""
state = await chaos.set_profile("light")
enabled = {flag for flag, v in state["flags"].items() if v}
assert enabled == {"slow_sanctions", "cold_cache"}, enabled
async def test_flag_change_is_live_no_restart(api, chaos, manifest):
"""G5: enabling n_plus_one via the API changes the query count for the very
next insights request -- with no container restart -- proving live
reconfiguration rather than timing noise."""
account_id = manifest["clean_account_id"]
await chaos.set_profile("clean")
with statement_count(POSTGRES_DSN) as baseline:
resp = await api.get(f"/api/accounts/{account_id}/insights")
assert resp.status_code == 200
await chaos.set_flag("n_plus_one", True)
with statement_count(POSTGRES_DSN) as degraded:
resp = await api.get(f"/api/accounts/{account_id}/insights")
assert resp.status_code == 200
assert degraded.calls > baseline.calls, (
f"expected more SQL statements after enabling n_plus_one live "
f"(baseline={baseline.calls}, degraded={degraded.calls})"
)
async def test_unknown_flag_returns_400(chaos):
"""G5: setting an unknown flag name returns 400."""
resp = await chaos.set_flag("not_a_real_flag", True)
assert resp.status_code == 400
+52
View File
@@ -0,0 +1,52 @@
"""G11 -- the frontend and its supporting endpoints are served. No browser
automation: serving and shape only."""
import re
import pytest
pytestmark = pytest.mark.contract
def _extract_asset_path(html, filename):
match = re.search(rf'(?:src|href)=["\']([^"\']*{re.escape(filename)})["\']', html)
assert match, f"could not find a reference to {filename} in the index page"
path = match.group(1)
return path if path.startswith("/") else f"/{path}"
async def test_index_serves_html_referencing_assets(api):
"""G11: GET / returns 200, text/html, and references app.js and style.css."""
resp = await api.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers.get("content-type", "")
body = resp.text
assert "app.js" in body
assert "style.css" in body
async def test_app_js_served(api):
"""G11: app.js returns 200 with a JS content type."""
index_html = (await api.get("/")).text
resp = await api.get(_extract_asset_path(index_html, "app.js"))
assert resp.status_code == 200
ctype = resp.headers.get("content-type", "")
assert "javascript" in ctype or "ecmascript" in ctype
async def test_style_css_served(api):
"""G11: style.css returns 200 with a CSS content type."""
index_html = (await api.get("/")).text
resp = await api.get(_extract_asset_path(index_html, "style.css"))
assert resp.status_code == 200
assert "css" in resp.headers.get("content-type", "")
async def test_accounts_list_endpoint(api):
"""G11: GET /api/accounts returns >= 100 accounts with the expected shape."""
resp = await api.get("/api/accounts")
assert resp.status_code == 200
accounts = resp.json()
assert len(accounts) >= 100
sample = accounts[0]
for field in ("id", "holder_name", "currency", "balance_minor"):
assert field in sample, sample
+118
View File
@@ -0,0 +1,118 @@
"""G2 -- the system is genuinely multi-service, not a collapsed monolith."""
import asyncio
import time
import httpx
import pytest
from harness import containers
from harness.config import HTTP_SERVICES, SERVICE_URLS
from harness.payloads import tx_payload
pytestmark = pytest.mark.contract
@pytest.mark.parametrize("service", HTTP_SERVICES)
async def test_service_healthz_reports_own_name(service):
"""G2: every HTTP service answers /healthz on its own port with its own name."""
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{SERVICE_URLS[service]}/healthz")
assert resp.status_code == 200
assert resp.json().get("service") == service
@pytest.mark.parametrize("service", HTTP_SERVICES)
async def test_service_readyz_ok(service):
"""G2: /readyz returns 200 for all six services when dependencies are healthy."""
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{SERVICE_URLS[service]}/readyz")
assert resp.status_code == 200
async def test_health_all_reports_all_services_ok(api):
"""G2: GET /api/health/all reports all six services as ok."""
resp = await api.get("/api/health/all")
assert resp.status_code == 200
services = resp.json()["services"]
assert set(services) == set(HTTP_SERVICES)
assert all(v == "ok" for v in services.values()), services
async def test_stopping_sanctions_breaks_transactions(api, manifest):
"""G2: stopping `sanctions` must make it report unhealthy within 15s and
transactions must fail with 502/504, not 200 -- the test that catches a
collapsed monolith (if transactions still succeed, sanctions isn't really
in the path)."""
containers.stop("sanctions")
try:
deadline = time.monotonic() + 15
reported_unhealthy = False
while time.monotonic() < deadline:
resp = await api.get("/api/health/all")
if resp.status_code == 200 and resp.json()["services"].get("sanctions") != "ok":
reported_unhealthy = True
break
await asyncio.sleep(1)
assert reported_unhealthy, "sanctions was not reported unhealthy within 15s of being stopped"
resp = await api.post(
"/api/transactions", json=tx_payload(manifest["clean_account_id"])
)
assert resp.status_code in (502, 504), (
f"expected 502/504 with sanctions stopped, got {resp.status_code}: {resp.text}"
)
finally:
containers.start("sanctions")
containers.wait_ready("sanctions", timeout=30)
# ---------------------------------------------------------------------------
# G9 -- failure is contained: timeouts fire, the gateway doesn't wedge.
# ---------------------------------------------------------------------------
async def test_sanctions_errors_timeout_never_exceeds_10s(api, chaos, manifest, fresh_balances):
"""G9: with sanctions_errors on and retry_storm off, no request takes
longer than 10s -- the 3s downstream timeout must actually fire."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
await chaos.set_profile("clean")
await chaos.set_flag("sanctions_errors", True)
for _ in range(20):
t0 = time.monotonic()
resp = await api.post("/api/transactions", json=tx_payload(account_id, amount_minor=100))
elapsed = time.monotonic() - t0
assert elapsed <= 10, f"request took {elapsed:.1f}s, expected <= 10s"
assert resp.status_code in (201, 502, 504), resp.status_code
async def test_gateway_healthz_stays_fast_while_sanctions_is_down(api):
"""G9: with sanctions stopped entirely, the gateway still answers
/healthz in under 1s throughout."""
containers.stop("sanctions")
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
t0 = time.monotonic()
resp = await api.get("/healthz")
elapsed = time.monotonic() - t0
assert resp.status_code == 200
assert elapsed < 1, f"/healthz took {elapsed:.2f}s while sanctions was down"
await asyncio.sleep(0.5)
finally:
containers.start("sanctions")
containers.wait_ready("sanctions", timeout=30)
async def test_no_200_response_ever_has_a_null_transaction_id(api, chaos, manifest, fresh_balances):
"""G9: no request ever returns a 200/201 with a null transaction_id, even
under combined chaos (nightmare profile)."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
await chaos.set_profile("nightmare")
for _ in range(20):
resp = await api.post("/api/transactions", json=tx_payload(account_id, amount_minor=100))
if resp.status_code in (200, 201):
body = resp.json()
assert body.get("transaction_id") is not None, body
+37
View File
@@ -0,0 +1,37 @@
"""G1 -- GET /api/accounts/{id}/insights: contract shape, not the analytics
themselves (the N+1 and missing-index differential live in test_pathologies.py)."""
import uuid
import pytest
pytestmark = pytest.mark.contract
async def test_insights_for_cold_account(api, manifest):
"""G1: insights works for an ordinary (non-hot) account."""
resp = await api.get(f"/api/accounts/{manifest['cold_account_ids_sample'][0]}/insights")
assert resp.status_code == 200
assert isinstance(resp.json(), dict) and resp.json()
async def test_insights_for_hot_account(api, manifest):
"""G1: insights also works for a hot, high-volume account."""
resp = await api.get(f"/api/accounts/{manifest['hot_account_ids'][0]}/insights")
assert resp.status_code == 200
assert isinstance(resp.json(), dict) and resp.json()
async def test_insights_unknown_account_404(api):
"""G1: insights for a nonexistent account returns 404 with the error envelope."""
resp = await api.get(f"/api/accounts/{uuid.uuid4()}/insights")
assert resp.status_code == 404
assert "error" in resp.json()
async def test_insights_repeated_calls_are_consistent(api, manifest):
"""G1: two immediate calls for the same account return the same aggregate figures."""
account_id = manifest["clean_account_id"]
first = await api.get(f"/api/accounts/{account_id}/insights")
second = await api.get(f"/api/accounts/{account_id}/insights")
assert first.status_code == second.status_code == 200
assert first.json() == second.json()
+140
View File
@@ -0,0 +1,140 @@
"""G1 -- a transaction traverses all seven services and returns a coherent result."""
import time
import uuid
import psycopg
import pytest
from harness.payloads import tx_payload
pytestmark = pytest.mark.contract
async def test_valid_transaction_returns_full_envelope(api, manifest, fresh_balances):
"""G1: a valid transaction returns 201 with the full envelope and coherent fields."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
resp = await api.post("/api/transactions", json=tx_payload(account_id))
assert resp.status_code == 201, resp.text
body = resp.json()
uuid.UUID(body["transaction_id"])
assert body["status"] in ("settled", "rejected")
assert 0.0 <= body["risk_score"] <= 1.0
assert isinstance(body["balance_minor"], int)
assert "summary" in body
uuid.UUID(body["request_id"])
assert body["elapsed_ms"] > 0
async def test_transaction_is_durable(api, manifest, fresh_balances, dsn):
"""G1: the transaction row exists with a terminal status within 2s."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
resp = await api.post("/api/transactions", json=tx_payload(account_id))
assert resp.status_code == 201
tx_id = resp.json()["transaction_id"]
deadline = time.monotonic() + 2
status = None
with psycopg.connect(dsn, autocommit=True) as conn:
while time.monotonic() < deadline:
with conn.cursor() as cur:
cur.execute("SELECT status FROM transactions WHERE id = %s", (tx_id,))
row = cur.fetchone()
if row and row[0] in ("settled", "rejected"):
status = row[0]
break
assert status in ("settled", "rejected"), f"transaction {tx_id} never reached a terminal status"
async def test_deposit_increases_balance_by_exact_amount(api, manifest, fresh_balances, dsn):
"""G1: a settled deposit moves the balance up by exactly amount_minor."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT balance_minor FROM accounts WHERE id = %s", (account_id,))
(before,) = cur.fetchone()
amount = 5000
resp = await api.post(
"/api/transactions", json=tx_payload(account_id, kind="deposit", amount_minor=amount)
)
assert resp.status_code == 201
body = resp.json()
if body["status"] == "settled":
assert body["balance_minor"] == before + amount
async def test_withdrawal_decreases_balance_by_exact_amount(api, manifest, fresh_balances, dsn):
"""G1: a settled withdrawal moves the balance down by exactly amount_minor."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT balance_minor FROM accounts WHERE id = %s", (account_id,))
(before,) = cur.fetchone()
amount = 1000
resp = await api.post(
"/api/transactions", json=tx_payload(account_id, kind="withdrawal", amount_minor=amount)
)
assert resp.status_code == 201
body = resp.json()
if body["status"] == "settled":
assert body["balance_minor"] == before - amount
async def test_sanctioned_holder_scores_higher_risk_than_clean(api, manifest, fresh_balances):
"""G1: a sanctioned account yields a materially higher risk_score than a
clean one (assert the ordering, not a value)."""
clean_id = manifest["clean_account_id"]
sanctioned_id = manifest["sanctioned_account_ids"][0]
fresh_balances(clean_id)
fresh_balances(sanctioned_id)
clean_resp = await api.post("/api/transactions", json=tx_payload(clean_id))
sanctioned_resp = await api.post("/api/transactions", json=tx_payload(sanctioned_id))
assert clean_resp.status_code == 201
assert sanctioned_resp.status_code == 201
clean_score = clean_resp.json()["risk_score"]
sanctioned_score = sanctioned_resp.json()["risk_score"]
assert sanctioned_score > clean_score, (
f"expected sanctioned account risk_score ({sanctioned_score}) > clean ({clean_score})"
)
async def test_insights_returns_summary_for_seeded_account(api, manifest):
"""G1: GET /api/accounts/{id}/insights returns a summary for a seeded account."""
resp = await api.get(f"/api/accounts/{manifest['clean_account_id']}/insights")
assert resp.status_code == 200
assert isinstance(resp.json(), dict) and resp.json()
async def test_unknown_account_returns_404(api):
"""G1: submitting a transaction for an unknown account returns 404 with the error envelope."""
resp = await api.post("/api/transactions", json=tx_payload(str(uuid.uuid4())))
assert resp.status_code == 404
body = resp.json()
assert "error" in body and "request_id" in body
async def test_negative_amount_on_deposit_is_rejected(api, manifest):
"""G1: a negative amount_minor on a deposit returns 400 or 422."""
resp = await api.post(
"/api/transactions",
json=tx_payload(manifest["clean_account_id"], kind="deposit", amount_minor=-500),
)
assert resp.status_code in (400, 422), resp.text
async def test_missing_required_field_returns_422(api, manifest):
"""G1: a missing required field returns 422."""
payload = tx_payload(manifest["clean_account_id"])
del payload["kind"]
resp = await api.post("/api/transactions", json=payload)
assert resp.status_code == 422
+47
View File
@@ -0,0 +1,47 @@
"""G8 -- nightmare mode degrades but does not collapse."""
import random
import time
import pytest
from harness.config import GATEWAY_URL
from harness.load import drive
pytestmark = pytest.mark.pathology
async def test_nightmare_degrades_but_survives(chaos, manifest, api):
"""G8: same driver, chaos-clean then chaos-nightmare, 300 requests at
concurrency 20 each. Nightmare p95 >= 5x clean p95; nightmare error rate
in [2%, 15%]; >= 80% of requests still return a valid envelope (success or
a structured error, never a connection reset or empty body);
GET /api/health/all still responds within 5s during the burst."""
accounts = manifest["cold_account_ids_sample"] + manifest["hot_account_ids"]
def url_factory():
account_id = random.choice(accounts)
return (f"{GATEWAY_URL}/api/accounts/{account_id}/insights", {})
await chaos.set_profile("clean")
clean_report = await drive("GET", url_factory, total=300, concurrency=20, warmup=20)
await chaos.set_profile("nightmare")
nightmare_report = await drive("GET", url_factory, total=300, concurrency=20, warmup=20)
assert nightmare_report.p95 >= 5 * clean_report.p95, (
f"nightmare p95 not >= 5x clean p95\nclean={clean_report}\nnightmare={nightmare_report}"
)
assert 0.02 <= nightmare_report.error_rate <= 0.15, str(nightmare_report)
valid_requests = nightmare_report.count - nightmare_report.exceptions
valid_share = valid_requests / nightmare_report.count
assert valid_share >= 0.80, (
f"only {valid_requests}/{nightmare_report.count} requests returned a "
f"valid envelope (no reset/empty body): {nightmare_report}"
)
start = time.perf_counter()
resp = await api.get("/api/health/all")
elapsed = time.perf_counter() - start
assert elapsed < 5, f"/api/health/all took {elapsed:.2f}s during the nightmare burst"
assert resp.status_code == 200
+100
View File
@@ -0,0 +1,100 @@
"""G10 -- no observability tooling is present yet.
This test module guards the Phase 0 "before" state. It is expected to be
DELETED at the start of Phase 2, not maintained -- once real instrumentation
is added, every assertion here becomes actively wrong on purpose.
"""
import json
import re
import subprocess
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from harness.config import HTTP_SERVICES, REPO_ROOT, SERVICE_URLS
pytestmark = pytest.mark.guard
FORBIDDEN_IMPORTS = ("opentelemetry", "prometheus_client", "structlog", "sentry_sdk", "elasticapm")
def _python_files():
files = []
for name in ("services", "common"):
base = REPO_ROOT / name
if base.is_dir():
files.extend(base.rglob("*.py"))
return files
def test_application_code_exists():
"""G10 (vacuous-pass guard): this module must not pass vacuously against
an empty repo -- there must be application code for the import check
below to actually inspect."""
files = _python_files()
assert files, (
"no .py files found under services/ or common/ -- either the app hasn't "
"been implemented yet, or the guard tests below would pass vacuously"
)
def test_no_file_imports_observability_libraries():
"""G10: no file under services/ or common/ imports an observability library."""
offenders = []
for path in _python_files():
text = path.read_text(errors="ignore")
for lib in FORBIDDEN_IMPORTS:
if re.search(rf"^\s*(import|from)\s+{re.escape(lib)}\b", text, re.MULTILINE):
offenders.append((str(path.relative_to(REPO_ROOT)), lib))
assert not offenders, offenders
def test_dependency_files_do_not_list_observability_libraries():
"""G10: neither requirements*.txt (excluding requirements-test.txt) nor
pyproject.toml lists an observability library."""
candidates = [p for p in REPO_ROOT.glob("requirements*.txt") if p.name != "requirements-test.txt"]
pyproject = REPO_ROOT / "pyproject.toml"
if pyproject.is_file():
candidates.append(pyproject)
offenders = []
for path in candidates:
text = path.read_text(errors="ignore").lower()
for lib in FORBIDDEN_IMPORTS:
if lib in text:
offenders.append((str(path.relative_to(REPO_ROOT)), lib))
assert not offenders, offenders
@pytest.mark.parametrize("service", HTTP_SERVICES)
async def test_no_service_exposes_metrics_endpoint(service):
"""G10: no service exposes /metrics."""
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{SERVICE_URLS[service]}/metrics")
assert resp.status_code == 404
async def test_gateway_logs_are_plain_text_not_json():
"""G10: docker compose logs gateway emits plain text, not JSON: sample 20
lines and assert none parse as a JSON object."""
async with httpx.AsyncClient(timeout=5.0) as client:
for _ in range(3):
await client.get(f"{SERVICE_URLS['gateway']}/healthz")
since = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat()
result = subprocess.run(
["docker", "compose", "logs", "gateway", "--since", since, "--no-color", "--no-log-prefix", "--tail", "20"],
cwd=REPO_ROOT, capture_output=True, text=True, timeout=15,
)
lines = [l for l in result.stdout.splitlines() if l.strip()][-20:]
assert lines, "no recent gateway log lines found"
json_lines = 0
for line in lines:
try:
json.loads(line)
json_lines += 1
except (json.JSONDecodeError, ValueError):
pass
assert json_lines == 0, f"{json_lines}/{len(lines)} gateway log lines parsed as JSON"
+512
View File
@@ -0,0 +1,512 @@
"""G6 -- one differential test per pathology (phase-0a-test-suite-spec.md §6).
Every test: set chaos-clean, warm up, measure baseline, enable exactly one
flag, warm up, measure degraded, assert the ratio with both raw numbers in the
failure message, then let the `chaos` fixture reset to clean in teardown.
6.4 (missing index) is not a chaos flag -- it's `make drop-index`/`fix-index`.
6.10 and 6.12 carry `slow` in addition to `pathology` (soak-ish; see
tests/README.md's known-flaky list).
"""
import asyncio
import random
import statistics
import subprocess
import time
from datetime import datetime, timezone
import httpx
import pytest
from harness.config import (
GATEWAY_URL,
POSTGRES_DSN,
REPO_ROOT,
SANCTIONS_LOOKUP_LOG_PATTERN,
HEALTHZ_LOG_PATTERN,
STREAM_NAME,
)
from harness.containers import count_log_lines, rss_mb
from harness.load import drive, measure_median
from harness.payloads import tx_payload
from harness.pg import explain, plan_node_types, statement_count
from harness.streams import group_lag, wait_for_drain
pytestmark = pytest.mark.pathology
INSIGHTS_AGGREGATION_QUERY = """
SELECT category_id, count(*), sum(amount_minor)
FROM transactions
WHERE account_id = %s AND created_at >= now() - interval '120 days'
GROUP BY category_id
"""
def _make(target, timeout=60):
subprocess.run(["make", target], cwd=REPO_ROOT, check=True, timeout=timeout)
# ---------------------------------------------------------------------------
# 6.1 slow_sanctions
# ---------------------------------------------------------------------------
async def test_slow_sanctions_degrades_tail_latency(chaos, manifest, fresh_balances):
"""G6 §6.1: degraded p99/p50 >= 6 (baseline <= 4); degraded p99 >= 5x baseline p99."""
accounts = manifest["cold_account_ids_sample"][:20]
for acc in accounts:
fresh_balances(acc)
def url_factory():
account_id = random.choice(accounts)
return (f"{GATEWAY_URL}/api/transactions", dict(json=tx_payload(account_id, amount_minor=100)))
async def run():
return await drive("POST", url_factory, total=60, concurrency=10, warmup=10)
await chaos.set_profile("clean")
baseline = await measure_median(3, run, key=lambda r: r.p95)
await chaos.set_flag("slow_sanctions", True)
degraded = await measure_median(3, run, key=lambda r: r.p95)
baseline_ratio = baseline.p99 / baseline.p50 if baseline.p50 else float("inf")
degraded_ratio = degraded.p99 / degraded.p50 if degraded.p50 else float("inf")
assert baseline_ratio <= 4, f"baseline p99/p50={baseline_ratio:.2f} ({baseline})"
assert degraded_ratio >= 6, f"degraded p99/p50={degraded_ratio:.2f} ({degraded})"
assert degraded.p99 >= 5 * baseline.p99, (
f"degraded p99 not >= 5x baseline p99\nbaseline={baseline}\ndegraded={degraded}"
)
# ---------------------------------------------------------------------------
# 6.2 sanctions_errors
# ---------------------------------------------------------------------------
async def test_sanctions_errors_produces_bounded_error_rate(chaos, manifest, fresh_balances):
"""G6 §6.2: degraded 502/504 rate in [1%, 10%] over 300 requests; baseline == 0."""
accounts = manifest["cold_account_ids_sample"][:20]
for acc in accounts:
fresh_balances(acc)
def url_factory():
account_id = random.choice(accounts)
return (f"{GATEWAY_URL}/api/transactions", dict(json=tx_payload(account_id, amount_minor=100)))
await chaos.set_profile("clean")
baseline = await drive("POST", url_factory, total=300, concurrency=20, warmup=20)
await chaos.set_flag("sanctions_errors", True)
degraded = await drive("POST", url_factory, total=300, concurrency=20, warmup=20)
gateway_errors = sum(n for status, n in degraded.status_counts.items() if status in (502, 504))
degraded_rate = gateway_errors / degraded.count
assert baseline.error_rate == 0, f"baseline had errors: {baseline}"
assert 0.01 <= degraded_rate <= 0.10, (
f"502/504 rate {degraded_rate:.1%} not in [1%,10%]\nbaseline={baseline}\ndegraded={degraded}"
)
# ---------------------------------------------------------------------------
# 6.3 n_plus_one
# ---------------------------------------------------------------------------
async def test_n_plus_one_multiplies_statement_count(chaos, manifest):
"""G6 §6.3: degraded issues >= 50 statements for one insights request (baseline
<= 10); latency ratio >= 2."""
account_id = manifest["hot_account_ids"][0]
async def one_request():
async with httpx.AsyncClient(timeout=15) as client:
t0 = time.perf_counter()
resp = await client.get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights")
elapsed = time.perf_counter() - t0
assert resp.status_code == 200
return elapsed
await chaos.set_profile("clean")
with statement_count(POSTGRES_DSN) as baseline_calls:
baseline_latency = await one_request()
await chaos.set_flag("n_plus_one", True)
with statement_count(POSTGRES_DSN) as degraded_calls:
degraded_latency = await one_request()
assert degraded_calls.calls >= 50, f"degraded issued {degraded_calls.calls} statements"
assert baseline_calls.calls <= 10, f"baseline issued {baseline_calls.calls} statements"
ratio = degraded_latency / baseline_latency if baseline_latency else float("inf")
assert ratio >= 2, (
f"latency ratio {ratio:.2f} not >= 2 "
f"(baseline={baseline_latency*1000:.1f}ms, degraded={degraded_latency*1000:.1f}ms)"
)
# ---------------------------------------------------------------------------
# 6.4 missing index (make drop-index / fix-index, not a chaos flag)
# ---------------------------------------------------------------------------
def test_missing_index_forces_seq_scan(manifest):
"""G6 §6.4: EXPLAIN (ANALYZE, FORMAT JSON) on the insights aggregation shows a
Seq Scan without the index and an Index/Bitmap Scan with it; unindexed
latency >= 3x indexed. Teardown restores the dropped state."""
account_id = manifest["hot_account_ids"][0]
_make("drop-index")
plan = explain(POSTGRES_DSN, INSIGHTS_AGGREGATION_QUERY, (account_id,))
unindexed_types = plan_node_types(plan)
unindexed_time = plan["Plan"]["Actual Total Time"]
assert "Seq Scan" in unindexed_types, unindexed_types
try:
_make("fix-index")
plan = explain(POSTGRES_DSN, INSIGHTS_AGGREGATION_QUERY, (account_id,))
indexed_types = plan_node_types(plan)
indexed_time = plan["Plan"]["Actual Total Time"]
assert indexed_types & {"Index Scan", "Bitmap Index Scan", "Bitmap Heap Scan"}, indexed_types
assert unindexed_time >= 3 * indexed_time, (
f"unindexed {unindexed_time:.2f}ms not >= 3x indexed {indexed_time:.2f}ms"
)
finally:
_make("drop-index")
# ---------------------------------------------------------------------------
# 6.5 pool_starvation -- no automated test.
#
# test_pool_starvation_plateaus_throughput (removed) asserted baseline
# throughput scales >= 2.5x from concurrency=5 to concurrency=25. Isolated
# reproduction (a bare GET /healthz with zero backend work, measured from
# inside the gateway container, bypassing Docker's networking entirely)
# showed the same flat-to-negative scaling regardless of any chaos flag:
# ~600rps at c5 vs ~480-530rps at c25. This is uvicorn running a single
# worker per service, exactly as phase-0-base-project-spec.md §3 mandates
# ("one worker per service") -- there is no second process/core for that one
# service to spread load across, so throughput cannot scale with added
# concurrency in this deployment regardless of pool_starvation. The
# assertion conflicted with the spec's own mandated architecture, not with
# this implementation; removed by user decision rather than loosened.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 6.6 row_lock_contention
# ---------------------------------------------------------------------------
async def test_row_lock_contention_on_hot_account(chaos, manifest, fresh_balances):
"""G6 §6.6: with the flag on, 25 concurrent transactions against one hot account
has p95 >= 3x the same burst spread across 25 cold accounts; with the flag
off, that ratio is <= 1.8."""
hot_account = manifest["hot_account_ids"][0]
cold_accounts = manifest["cold_account_ids_sample"][:25]
fresh_balances(hot_account)
for acc in cold_accounts:
fresh_balances(acc)
def hot_factory():
return (f"{GATEWAY_URL}/api/transactions", dict(json=tx_payload(hot_account, amount_minor=100)))
def spread_factory():
account_id = random.choice(cold_accounts)
return (f"{GATEWAY_URL}/api/transactions", dict(json=tx_payload(account_id, amount_minor=100)))
await chaos.set_profile("clean")
off_hot = await drive("POST", hot_factory, total=25, concurrency=25, warmup=5)
off_spread = await drive("POST", spread_factory, total=25, concurrency=25, warmup=5)
off_ratio = off_hot.p95 / off_spread.p95 if off_spread.p95 else float("inf")
await chaos.set_flag("row_lock_contention", True)
on_hot = await drive("POST", hot_factory, total=25, concurrency=25, warmup=5)
on_spread = await drive("POST", spread_factory, total=25, concurrency=25, warmup=5)
on_ratio = on_hot.p95 / on_spread.p95 if on_spread.p95 else float("inf")
assert on_ratio >= 3, f"flag on: hot={on_hot} spread={on_spread} ratio={on_ratio:.2f}"
assert off_ratio <= 1.8, f"flag off: hot={off_hot} spread={off_spread} ratio={off_ratio:.2f}"
# ---------------------------------------------------------------------------
# 6.7 blocking_event_loop
# ---------------------------------------------------------------------------
async def test_blocking_event_loop_serializes_concurrent_requests(chaos, manifest, fresh_balances):
"""G6 §6.7: wall-clock for 10 concurrent requests / wall-clock for 1 request.
Degraded ratio >= 6 (serialised); baseline ratio <= 2.5 (concurrent)."""
accounts = manifest["cold_account_ids_sample"][:10]
for acc in accounts:
fresh_balances(acc)
async def one_request(account_id):
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
assert resp.status_code == 201
async def single_wall_clock():
t0 = time.perf_counter()
await one_request(accounts[0])
return time.perf_counter() - t0
async def concurrent_wall_clock():
t0 = time.perf_counter()
await asyncio.gather(*[one_request(acc) for acc in accounts])
return time.perf_counter() - t0
await chaos.set_profile("clean")
baseline_single = await single_wall_clock()
baseline_concurrent = await concurrent_wall_clock()
baseline_ratio = baseline_concurrent / baseline_single
await chaos.set_flag("blocking_event_loop", True)
degraded_single = await single_wall_clock()
degraded_concurrent = await concurrent_wall_clock()
degraded_ratio = degraded_concurrent / degraded_single
assert degraded_ratio >= 6, (
f"degraded: 10-concurrent {degraded_concurrent:.2f}s vs 1-request "
f"{degraded_single:.2f}s ratio={degraded_ratio:.2f}"
)
assert baseline_ratio <= 2.5, (
f"baseline: 10-concurrent {baseline_concurrent:.2f}s vs 1-request "
f"{baseline_single:.2f}s ratio={baseline_ratio:.2f}"
)
# ---------------------------------------------------------------------------
# 6.8 retry_storm (with sanctions_errors also on)
# ---------------------------------------------------------------------------
async def test_retry_storm_multiplies_sanctions_lookups(chaos, manifest, fresh_balances):
"""G6 §6.8: with sanctions_errors also on, degraded >= 2.0 sanctions lookup log
lines per transaction; baseline (retry off, errors on) <= 1.2."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
n = 20
async def burst():
since = datetime.now(timezone.utc)
async with httpx.AsyncClient(timeout=15) as client:
for _ in range(n):
await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
return count_log_lines("sanctions", SANCTIONS_LOOKUP_LOG_PATTERN, since)
await chaos.set_profile("clean")
await chaos.set_flag("sanctions_errors", True)
baseline_lookups = await burst()
baseline_ratio = baseline_lookups / n
await chaos.set_flag("retry_storm", True)
degraded_lookups = await burst()
degraded_ratio = degraded_lookups / n
assert degraded_ratio >= 2.0, f"degraded {degraded_ratio:.2f} lookups/tx ({degraded_lookups}/{n})"
assert baseline_ratio <= 1.2, f"baseline {baseline_ratio:.2f} lookups/tx ({baseline_lookups}/{n})"
# ---------------------------------------------------------------------------
# 6.9 no_batching (sanctions_errors off)
# ---------------------------------------------------------------------------
async def test_no_batching_multiplies_sanctions_lookups(chaos, manifest, fresh_balances):
"""G6 §6.9: sanctions_errors off. Degraded >= 2.0 lookups/tx (baseline <= 1.2);
latency ratio >= 1.5."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
n = 20
async def burst():
since = datetime.now(timezone.utc)
t0 = time.perf_counter()
async with httpx.AsyncClient(timeout=15) as client:
for _ in range(n):
await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
elapsed = time.perf_counter() - t0
return count_log_lines("sanctions", SANCTIONS_LOOKUP_LOG_PATTERN, since), elapsed
await chaos.set_profile("clean")
baseline_lookups, baseline_elapsed = await burst()
baseline_ratio = baseline_lookups / n
await chaos.set_flag("no_batching", True)
degraded_lookups, degraded_elapsed = await burst()
degraded_ratio = degraded_lookups / n
assert degraded_ratio >= 2.0, f"degraded {degraded_ratio:.2f} lookups/tx"
assert baseline_ratio <= 1.2, f"baseline {baseline_ratio:.2f} lookups/tx"
latency_ratio = (degraded_elapsed / n) / (baseline_elapsed / n)
assert latency_ratio >= 1.5, (
f"latency ratio {latency_ratio:.2f} not >= 1.5 "
f"(baseline {baseline_elapsed/n*1000:.1f}ms/tx, degraded {degraded_elapsed/n*1000:.1f}ms/tx)"
)
# ---------------------------------------------------------------------------
# 6.10 unbounded_cache (slow)
# ---------------------------------------------------------------------------
@pytest.mark.slow
async def test_unbounded_cache_leaks_memory(chaos, manifest):
"""G6 §6.10: RSS growth after 2000 requests with distinct cache keys (the
pathology keys on (account_id, timestamp_ms), so plain repeated calls
already miss every time), sampled 30s after the burst. Degraded growth >=
20MB and still elevated at +30s; baseline growth <= 8MB."""
account_id = manifest["hot_account_ids"][0]
async def burst_distinct(n):
async with httpx.AsyncClient(timeout=15) as client:
sem = asyncio.Semaphore(20)
async def one():
async with sem:
await client.get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights")
await asyncio.gather(*[one() for _ in range(n)])
await chaos.set_profile("clean")
before = rss_mb("insights")
await burst_distinct(2000)
await asyncio.sleep(30)
baseline_growth = rss_mb("insights") - before
await chaos.set_flag("unbounded_cache", True)
before2 = rss_mb("insights")
await burst_distinct(2000)
await asyncio.sleep(30)
degraded_growth = rss_mb("insights") - before2
assert baseline_growth <= 8, f"baseline RSS grew {baseline_growth:.1f}MB"
assert degraded_growth >= 20, f"degraded RSS grew {degraded_growth:.1f}MB"
# ---------------------------------------------------------------------------
# 6.11 slow_consumer
# ---------------------------------------------------------------------------
async def test_slow_consumer_builds_lag(chaos, manifest, fresh_balances, redis_client):
"""G6 §6.11: after a 150-transaction burst, degraded notifier lag >= 30 at burst
end and still > 0 after 10s; baseline drains to 0 within 10s."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
async def burst(n):
async with httpx.AsyncClient(timeout=15) as client:
sem = asyncio.Semaphore(20)
async def one():
async with sem:
await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
await asyncio.gather(*[one() for _ in range(n)])
await chaos.set_profile("clean")
await burst(150)
wait_for_drain(redis_client, STREAM_NAME, "notifier", timeout=10)
await chaos.set_flag("slow_consumer", True)
await burst(150)
lag_at_end = group_lag(redis_client, STREAM_NAME, "notifier").get("lag") or 0
assert lag_at_end >= 30, f"lag at burst end was {lag_at_end}, want >= 30"
await asyncio.sleep(10)
lag_after = group_lag(redis_client, STREAM_NAME, "notifier").get("lag") or 0
assert lag_after > 0, f"expected lag still > 0 after 10s, got {lag_after}"
# ---------------------------------------------------------------------------
# 6.12 cold_cache (slow)
# ---------------------------------------------------------------------------
@pytest.mark.slow
async def test_cold_cache_produces_periodic_spikes(chaos, manifest):
"""G6 §6.12: same insights request at 1rps for 150s; count samples > 3x the
median. Degraded >= 2 spikes (a 60s TTL over 150s); baseline <= 1."""
account_id = manifest["hot_account_ids"][0]
async def sample_for_150s():
latencies = []
async with httpx.AsyncClient(timeout=15) as client:
for _ in range(150):
t0 = time.perf_counter()
await client.get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights")
latencies.append((time.perf_counter() - t0) * 1000)
await asyncio.sleep(1)
return latencies
def count_spikes(latencies):
median = statistics.median(latencies)
return sum(1 for l in latencies if l > 3 * median)
await chaos.set_profile("clean")
baseline_latencies = await sample_for_150s()
baseline_spikes = count_spikes(baseline_latencies)
await chaos.set_flag("cold_cache", True)
degraded_latencies = await sample_for_150s()
degraded_spikes = count_spikes(degraded_latencies)
assert baseline_spikes <= 1, f"baseline had {baseline_spikes} spikes: {baseline_latencies}"
assert degraded_spikes >= 2, f"degraded had {degraded_spikes} spikes: {degraded_latencies}"
# ---------------------------------------------------------------------------
# 6.13 poison_data
# ---------------------------------------------------------------------------
async def test_poison_data_causes_unhandled_exceptions(chaos, manifest):
"""G6 §6.13: requests against poison accounts produce >= 1 HTTP 500 when the
flag is on; baseline produces zero 500s (4xx or 200/201 only)."""
poison_accounts = manifest["poison_account_ids"]
async def probe():
statuses = []
async with httpx.AsyncClient(timeout=15) as client:
for account_id in poison_accounts:
resp = await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
statuses.append(resp.status_code)
return statuses
await chaos.set_profile("clean")
baseline_statuses = await probe()
assert all(s in (200, 201) or 400 <= s < 500 for s in baseline_statuses), baseline_statuses
await chaos.set_flag("poison_data", True)
degraded_statuses = await probe()
assert 500 in degraded_statuses, degraded_statuses
# ---------------------------------------------------------------------------
# 6.14 chatty_health
# ---------------------------------------------------------------------------
async def test_chatty_health_polls_downstreams(chaos, manifest, fresh_balances):
"""G6 §6.14: on a single transaction, degraded produces >= 5 healthz log lines
across downstream services; baseline == 0."""
account_id = manifest["clean_account_id"]
fresh_balances(account_id)
downstreams = ["ledger", "fraud", "sanctions", "insights", "notifier"]
async def one_transaction_healthz_hits():
since = datetime.now(timezone.utc)
async with httpx.AsyncClient(timeout=15) as client:
await client.post(
f"{GATEWAY_URL}/api/transactions", json=tx_payload(account_id, amount_minor=100)
)
return sum(count_log_lines(svc, HEALTHZ_LOG_PATTERN, since) for svc in downstreams)
await chaos.set_profile("clean")
baseline_hits = await one_transaction_healthz_hits()
assert baseline_hits == 0, f"baseline had {baseline_hits} healthz hits: expected 0"
await chaos.set_flag("chatty_health", True)
degraded_hits = await one_transaction_healthz_hits()
assert degraded_hits >= 5, f"degraded had {degraded_hits} healthz hits: expected >= 5"
+164
View File
@@ -0,0 +1,164 @@
"""G4 -- the seed data is large, skewed, and contains the intended poison rows."""
import os
import subprocess
from pathlib import Path
import psycopg
import pytest
from harness.config import POSTGRES_DSN, REPO_ROOT
pytestmark = pytest.mark.seed
TARGET_COUNTS = {
"accounts": 800,
"transactions": 250_000,
"merchant_categories": 45,
"sanctions_entries": 12_000,
}
@pytest.fixture
def db_cursor(dsn, seeded):
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
yield cur
def _count(cur, table):
cur.execute(f"SELECT count(*) FROM {table}")
return cur.fetchone()[0]
def test_row_counts_match_manifest_and_targets(manifest, db_cursor):
"""G4: row counts match the manifest and the spec's targets within +-2%.
`transactions` is compared with >= rather than == : `make test-contract`
runs contract tests (which legitimately create new transactions) ahead of
this one in the same session (alphabetical file collection order), so its
count can only grow past the seed-time manifest snapshot, never shrink or
diverge below it. The other three tables are never mutated by any test,
so they stay an exact match.
"""
for table, target in TARGET_COUNTS.items():
actual = _count(db_cursor, table)
manifest_count = manifest["counts"][table]
if table == "transactions":
assert actual >= manifest_count, f"{table}: db={actual} manifest={manifest_count}"
else:
assert actual == manifest_count, f"{table}: db={actual} manifest={manifest_count}"
tolerance = target * 0.02
assert abs(actual - target) <= tolerance, f"{table}: {actual} not within 2% of {target}"
def test_exactly_five_hot_accounts_matching_manifest(manifest, db_cursor):
"""G4: exactly 5 accounts have is_hot = true, and they match hot_account_ids."""
db_cursor.execute("SELECT id FROM accounts WHERE is_hot = true")
hot_ids = {str(r[0]) for r in db_cursor.fetchall()}
assert len(hot_ids) == 5, hot_ids
assert hot_ids == set(manifest["hot_account_ids"])
def test_hot_accounts_own_20_to_40_percent_of_transactions(manifest, db_cursor):
"""G4: the 5 hot accounts own between 20% and 40% of all transactions."""
hot_ids = list(manifest["hot_account_ids"])
db_cursor.execute("SELECT count(*) FROM transactions WHERE account_id = ANY(%s)", (hot_ids,))
hot_count = db_cursor.fetchone()[0]
total = _count(db_cursor, "transactions")
share = hot_count / total
assert 0.20 <= share <= 0.40, f"hot accounts own {share:.1%} of transactions"
def test_transactions_span_at_least_110_distinct_days(db_cursor):
"""G4: transactions span >= 110 distinct days."""
db_cursor.execute("SELECT count(DISTINCT created_at::date) FROM transactions")
(days,) = db_cursor.fetchone()
assert days >= 110, days
def test_currency_distribution_roughly_70_20_10(db_cursor):
"""G4: currency distribution is roughly 70/20/10 EUR/USD/GBP (+-10 points each)."""
db_cursor.execute("SELECT currency, count(*) FROM transactions GROUP BY currency")
rows = dict(db_cursor.fetchall())
total = sum(rows.values())
expected = {"EUR": 0.70, "USD": 0.20, "GBP": 0.10}
for currency, target in expected.items():
actual = rows.get(currency, 0) / total
assert abs(actual - target) <= 0.10, f"{currency}: {actual:.1%} vs target {target:.1%}"
def test_status_distribution_roughly_92_5_3(db_cursor):
"""G4: status distribution is roughly 92/5/3 settled/rejected/pending (+-5 points)."""
db_cursor.execute("SELECT status, count(*) FROM transactions GROUP BY status")
rows = dict(db_cursor.fetchall())
total = sum(rows.values())
expected = {"settled": 0.92, "rejected": 0.05, "pending": 0.03}
for status, target in expected.items():
actual = rows.get(status, 0) / total
assert abs(actual - target) <= 0.05, f"{status}: {actual:.1%} vs target {target:.1%}"
def test_malformed_rows_cover_all_five_varieties(db_cursor):
"""G4: malformed rows exist in all five described varieties, 1%-4% overall."""
total = _count(db_cursor, "transactions")
db_cursor.execute("SELECT count(*) FROM transactions WHERE currency IS NULL")
null_currency = db_cursor.fetchone()[0]
assert null_currency >= 1, "no null-currency rows found"
db_cursor.execute("SELECT count(*) FROM transactions WHERE kind = 'deposit' AND amount_minor < 0")
negative_deposit = db_cursor.fetchone()[0]
assert negative_deposit >= 1, "no negative-amount deposit rows found"
db_cursor.execute(r"SELECT count(*) FROM transactions WHERE merchant_name ~ '[^\x00-\x7F]'")
non_ascii = db_cursor.fetchone()[0]
assert non_ascii >= 1, "no non-ASCII merchant_name rows found"
db_cursor.execute("SELECT count(*) FROM transactions WHERE created_at > now()")
future = db_cursor.fetchone()[0]
assert future >= 1, "no future-dated rows found"
db_cursor.execute(
"""
SELECT count(*) FROM transactions t
WHERE t.category_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM merchant_categories c WHERE c.id = t.category_id)
"""
)
orphan_category = db_cursor.fetchone()[0]
assert orphan_category >= 1, "no orphan category_id rows found"
malformed_total = null_currency + negative_deposit + non_ascii + future + orphan_category
share = malformed_total / total
assert 0.01 <= share <= 0.04, f"malformed rows are {share:.1%} of total (want 1%-4%)"
def test_sanctions_entries_match_seeded_holders(db_cursor):
"""G4: at least 30 sanctions entries match names of seeded account holders."""
db_cursor.execute(
"""
SELECT count(DISTINCT s.id) FROM sanctions_entries s
JOIN accounts a ON a.holder_name = s.normalized_name
"""
)
(matches,) = db_cursor.fetchone()
assert matches >= 30, matches
def test_manifest_records_the_configured_seed(manifest):
"""G4 (determinism proxy): the manifest records the SEED value used to
generate it. A full re-seed-and-diff is a manual check -- see
tests/README.md, "Seed determinism" -- because it takes up to 60s and would
blow the <20s seed-marker / <60s test-contract budgets on every run."""
expected_seed = int(os.environ.get("SEED", "42"))
assert manifest["seed"] == expected_seed
def test_no_missing_indexes_present_after_fresh_seed(db_cursor):
"""G4: neither transactions(account_id, created_at) nor
transactions(category_id) exists in pg_indexes after a fresh seed."""
db_cursor.execute("SELECT indexdef FROM pg_indexes WHERE tablename = 'transactions'")
defs = [row[0].lower() for row in db_cursor.fetchall()]
for d in defs:
assert not ("account_id" in d and "created_at" in d), d
assert "(category_id)" not in d.replace(" ", ""), d