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:
+12
@@ -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)
|
||||||
@@ -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
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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})"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user