Files
LedgerLine/docs/phase-0a-test-suite-spec.md
Movses Aslanyan eb99cdbb8c Add project documentation and Claude instructions
Roadmap, phase specs, pathology catalog, and CLAUDE.md establishing
Ledgerline as an observability learning project built on a deliberately
imperfect seven-service payments backend.
2026-09-05 08:11:00 +03:30

509 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 0a — Technical Spec: Ledgerline integration test suite
> **How to use this file:** paste it into the **test-authoring agent** together
> with `phase-0-base-project-spec.md`. That agent writes only `tests/`.
> A second agent then implements the services against the resulting suite.
> See §1 for the workflow contract between them.
---
## 1. The two-agent workflow
### Agent A — test author (this spec)
- Reads `phase-0-base-project-spec.md` (the system contract) and this file.
- Creates **only** these paths:
```
tests/** pytest.ini (or pyproject [tool.pytest])
requirements-test.txt
tests/README.md
tests/RED-BASELINE.md
```
Plus the `test-*` targets appended to the `Makefile`.
- Creates **nothing** under `services/`, `common/`, `db/`, `scripts/`,
`observability/` or `loadtest/`. Not even a stub. If a test needs a fixture
from the system, it asserts against the contract in §4 rather than importing
application code.
- **Imports nothing from the application.** Tests talk to the system over HTTP,
Postgres, Redis, and the Docker CLI. No `from services.gateway import …`.
This is what keeps them behavioural rather than implementation-coupled.
- Runs the suite against an empty repo and confirms it is **red**, then writes
`tests/RED-BASELINE.md` listing, per test, *why* it currently fails
(connection refused / 404 / assertion / missing table). A test that passes
against nothing is a broken test — find it and fix it.
### Agent B — implementer
- Reads `phase-0-base-project-spec.md` and treats `tests/` as a **read-only
contract**.
- **Must not modify, delete, skip, xfail, or loosen any test**, or change
`pytest.ini`, markers, thresholds, or fixtures. If a test appears wrong,
impossible, or contradictory: **stop and report it**, don't edit it.
- Must implement each pathology by the mechanism described in
`phase-0-base-project-spec.md` §7 — not as a generic `sleep()` tuned to make a
timing assertion pass. Several assertions in §6 are deliberately built so that
faking is harder than implementing (query counts, `EXPLAIN` plan shapes, Redis
consumer lag, process RSS). Treat that as the intent everywhere, including
where the assertion happens to be simpler.
- Done when `make test` is green three consecutive runs.
---
## 2. Design principles for this suite
These are the rules the tests must obey. They matter more than any individual
test.
1. **Assert goals, not implementation.** No test may reference a function name,
module path, class, SQL string, log format, or internal attribute. A test may
reference the *public contract*: HTTP routes, status codes, JSON field names,
table names, Redis stream and consumer-group names, chaos flag names.
2. **Prefer assertions that are hard to satisfy without the real mechanism.**
Wall-clock latency alone is fakeable with `sleep()`. Query counts from
`pg_stat_statements`, `EXPLAIN` plan node types, Redis consumer-group lag,
container RSS, and log-line counts on a *different service* are not. Where
both are available, assert on both.
3. **Measure differentially, never absolutely.** With one exception (§5.7),
every pathology test compares flag-on against flag-off in the same run, on
the same machine, and asserts a *ratio*. Absolute millisecond thresholds do
not survive a different laptop.
4. **Design for a noisy laptop.** Generous margins, warm-up requests discarded,
median-of-N measurement runs. A test that fails when Slack is open is worse
than no test.
5. **Fast tests and slow tests are separate.** `make test-contract` must run in
under 60 seconds. Pathology tests may take minutes and live behind their own
marker.
6. **Every test restores state.** A test that enables a chaos flag must reset
the profile in teardown, even on failure.
---
## 3. Stack, layout, and execution
Tests run **on the host**, against the Compose stack on `localhost`. They do not
run inside a container and do not use testcontainers — the stack is started by
`make up` beforehand.
```
tests/
├─ README.md
├─ RED-BASELINE.md
├─ conftest.py # fixtures: clients, chaos control, seed manifest
├─ harness/
│ ├─ load.py # concurrent request driver + LatencyReport
│ ├─ pg.py # pg_stat_statements deltas, EXPLAIN helpers
│ ├─ streams.py # Redis XINFO GROUPS helpers
│ └─ containers.py # docker stats / docker logs helpers
├─ test_contract_health.py
├─ test_contract_transactions.py
├─ test_contract_insights.py
├─ test_contract_async.py
├─ test_contract_chaos.py
├─ test_contract_frontend.py
├─ test_seed_data.py
├─ test_baseline_health.py
├─ test_pathologies.py
├─ test_nightmare.py
└─ test_no_observability.py
```
Dependencies (`requirements-test.txt`): `pytest`, `pytest-asyncio`,
`pytest-timeout`, `httpx`, `psycopg[binary]`, `redis`. Docker access via
`subprocess` calling the `docker` CLI — no SDK.
### Markers
| Marker | Runtime | Contents |
|---|---|---|
| `contract` | < 60 s | Functional behaviour, chaos-clean |
| `seed` | < 20 s | Seed data integrity |
| `baseline` | < 90 s | The clean system is genuinely healthy |
| `pathology` | 5–12 min | One test per pathology, differential |
| `slow` | — | Additional marker on the soak-ish ones (`unbounded_cache`, `cold_cache`) |
| `guard` | < 5 s | The "no observability yet" assertions |
Configure `pytest.ini` with `--strict-markers`, `asyncio_mode = auto`, and a
global `timeout = 300`.
### Make targets to append
| Target | Command |
|---|---|
| `test` | everything except `slow` |
| `test-contract` | `-m "contract or seed or guard"` |
| `test-baseline` | `-m baseline` |
| `test-pathology` | `-m "pathology and not slow"` |
| `test-slow` | `-m slow` |
| `test-all` | no marker filter |
---
## 4. Contract additions required for testability
These extend `phase-0-base-project-spec.md`. Agent B must implement them; Agent A
may assume them.
### 4.1 Seed manifest
`scripts/seed.py` writes `db/seed_manifest.json` **and** a `seed_manifest` table
(single row, `data jsonb`) with the same content. Tests read the table, so they
don't depend on a host file path.
```json
{
"seed": 42,
"generated_at": "…",
"counts": { "accounts": 800, "transactions": 250000,
"merchant_categories": 45, "sanctions_entries": 12000,
"malformed_transactions": 5013 },
"hot_account_ids": ["…", "…", "…", "…", "…"],
"cold_account_ids_sample": ["…"], // 50 non-hot accounts, for spread tests
"sanctioned_account_ids": ["…"], // accounts whose holder matches the watchlist
"poison_account_ids": ["…"], // accounts owning malformed rows
"clean_account_id": "…" // no malformed rows, not hot, not sanctioned
}
```
### 4.2 Response envelope
`POST /api/transactions` → **201** on success:
```json
{ "transaction_id": "<uuid>", "status": "settled|rejected",
"risk_score": 0.0-1.0, "balance_minor": 0,
"summary": { }, "request_id": "<uuid>", "elapsed_ms": 0 }
```
Errors return a consistent envelope with the appropriate status:
```json
{ "error": "<machine_readable_code>", "detail": "<human string>",
"request_id": "<uuid>" }
```
Status mapping: `400` invalid input, `404` unknown account, `422` validation,
`502` downstream returned an error, `504` downstream timed out, `500`
unhandled — which is exactly what `poison_data` produces, and what §6.12 asserts.
### 4.3 Chaos API
- `GET /api/admin/chaos` → `{"profile": "<name|custom>", "flags": {"<flag>": bool, …}}`
containing **all thirteen** flags, always.
- `POST /api/admin/chaos` → `{"flag": "<name>", "enabled": bool}`; returns the
full new state. Unknown flag → `400`.
- `POST /api/admin/chaos/profile` → `{"profile": "clean|light|nightmare|random"}`;
returns the full new state. For `random`, the response reports the flags —
**but `make chaos-random` must not print them**, and the seed is never logged.
- `GET /api/health/all` → `{"services": {"<name>": "ok"|"unhealthy", …}}` for all
six HTTP services.
### 4.4 `pg_stat_statements`
The Postgres image must load `pg_stat_statements` (`shared_preload_libraries`)
and the extension must be created by `db/schema.sql`. Tests use
`pg_stat_statements_reset()` around a single request to count queries.
### 4.5 Deterministic seeding
`SEED` env var, default `42`. Same seed ⇒ identical manifest, identical row
counts, identical hot/poison account IDs.
---
## 5. Goal-to-test traceability
Every test must map to one of these goals. Put the goal ID in the test's
docstring. This is what keeps the suite about intent rather than mechanics.
| ID | Goal | Where |
|---|---|---|
| **G1** | A transaction traverses all seven services and returns a coherent result | §5.1 |
| **G2** | The system is genuinely multi-service, not a collapsed monolith | §5.2 |
| **G3** | Async work happens after the response, via two independent consumer groups | §5.3 |
| **G4** | The seed data is large, skewed, and contains the intended poison rows | §5.4 |
| **G5** | Chaos flags toggle at runtime without a restart | §5.5 |
| **G6** | Each pathology measurably degrades the system when enabled | §6 |
| **G7** | The clean baseline is actually healthy — otherwise G6 means nothing | §5.6 |
| **G8** | Nightmare mode degrades but does not collapse | §5.7 |
| **G9** | Failure is contained: timeouts fire, the gateway doesn't wedge | §5.8 |
| **G10** | No observability tooling is present yet | §5.9 |
| **G11** | The frontend and its supporting endpoints are served | §5.10 |
### 5.1 G1 — end-to-end transaction (`contract`)
- Submitting a valid transaction for `clean_account_id` returns 201 with the full
envelope; `transaction_id` is a UUID; `status` ∈ {settled, rejected};
`risk_score` ∈ [0,1]; `elapsed_ms` > 0.
- The transaction is durable: a row with that ID exists in `transactions` with a
terminal status within 2 s.
- The balance moves in the correct direction for a deposit and for a withdrawal,
by exactly `amount_minor`, when the transaction settles.
- A transaction for a `sanctioned_account_ids` holder yields a materially higher
`risk_score` than one for `clean_account_id` (assert the ordering, not a value).
- `GET /api/accounts/{id}/insights` returns a summary for a seeded account.
- Unknown account → 404 with the error envelope. Negative amount on a deposit →
400 or 422. Missing required field → 422.
### 5.2 G2 — really seven services (`contract`)
- All six HTTP services answer `/healthz` on their own port with their own name.
- `/readyz` returns 200 for all six.
- `GET /api/health/all` reports all six as ok.
- Stopping `sanctions` (`docker compose stop sanctions`) causes
`GET /api/health/all` to report it unhealthy within 15 s, and
`POST /api/transactions` to fail with 502 or 504 — **not** 200. Restart it in
teardown and wait for ready.
*(This is the test that catches a collapsed monolith: if transactions still
succeed with `sanctions` stopped, the service isn't really in the path.)*
### 5.3 G3 — asynchronous work (`contract`)
- After a successful transaction, a row appears in `notifications` **and** in
`audit_events` for that `transaction_id` within 10 s.
- Both appear — proving two independent consumer groups, not one consumer doing
both jobs.
- `XINFO GROUPS stream:transactions` reports exactly two groups, named
`notifier` and `audit`.
- The API response returns **before** the notification row exists at least once
across 20 attempts, proving the work is genuinely off the request path.
*(Sample the DB immediately on response; assert at least one miss. Tolerant of
a fast machine winning the race sometimes — hence 20 attempts, not 1.)*
### 5.4 G4 — seed data (`seed`)
Against the manifest and the database:
- Row counts match the manifest and the spec's targets within ±2%.
- Exactly 5 accounts have `is_hot = true`, and they match `hot_account_ids`.
- The 5 hot accounts own between 20% and 40% of all transactions.
- Transactions span ≥ 110 distinct days.
- Currency distribution is roughly 70/20/10 EUR/USD/GBP (±10 points each).
- Status distribution ≈ 92/5/3 settled/rejected/pending (±5 points).
- Malformed rows exist in all five described varieties: null currency, negative
amount on a deposit, non-ASCII merchant name, future `created_at`, orphan
`category_id`. Assert **at least one of each**, and 1%–4% overall.
- ≥ 30 sanctions entries match seeded holder names.
- Re-running the seed with the same `SEED` yields an identical manifest
(compare the `counts` and `hot_account_ids`).
- Neither `transactions(account_id, created_at)` nor `transactions(category_id)`
exists in `pg_indexes` after a fresh seed.
### 5.5 G5 — chaos control (`contract`)
- `GET /api/admin/chaos` lists all thirteen flags by name.
- Toggling a single flag changes only that flag.
- Each profile sets the expected shape: `clean` → all false; `nightmare` → all
true; `light` → exactly the two documented flags.
- **No restart:** with `chaos-clean` set, record the `insights` latency; enable
`n_plus_one` via the API; without restarting any container, the query count for
the same request rises. Assert on the query-count delta (§6.3's helper), so
this test proves live reconfiguration rather than timing noise.
- Unknown flag name → 400.
- Teardown restores `clean`.
### 5.6 G7 — the clean baseline is healthy (`baseline`)
With `chaos-clean`, after 20 discarded warm-up requests, drive 200 requests at
concurrency 10 against random cold accounts:
- error rate == 0
- p95 < 400 ms *(the one absolute threshold in the functional set; if it fails on
a slow machine, that machine cannot run the pathology suite meaningfully
either, and the failure message must say so)*
- p99 / p50 < 5 — the clean system has no fat tail
- `insights` for a cold account issues fewer than 10 SQL statements
- Redis consumer lag returns to 0 within 15 s of the burst finishing
### 5.7 G8 — nightmare degrades but survives (`pathology`)
Same driver, `chaos-clean` then `chaos-nightmare`, 300 requests at concurrency 20
each:
- nightmare p95 ≥ **5×** clean p95
- nightmare error rate between **2% and 15%** — degraded, not dead
- at least 80% of requests still return a valid envelope (either success or a
structured error, never a connection reset or an empty body)
- `GET /api/health/all` still responds within 5 s during the burst
### 5.8 G9 — failure containment (`contract`)
- With `sanctions_errors` at its maximum and `retry_storm` off, no request takes
longer than 10 s. The 3 s downstream timeout must actually fire.
- With `sanctions` stopped entirely, `POST /api/transactions` returns 502/504
within 10 s, and the gateway still answers `/healthz` in under 1 s throughout.
- No request ever returns a 200 with a `null` `transaction_id`.
### 5.9 G10 — no observability yet (`guard`)
Static and runtime assertions that the Phase 0 "before" state is intact:
- No file under `services/` or `common/` imports `opentelemetry`,
`prometheus_client`, `structlog`, `sentry_sdk`, or `elasticapm`.
- Neither `requirements*.txt` nor `pyproject.toml` lists them (excluding
`requirements-test.txt`).
- No service exposes `/metrics` (assert 404 on all six).
- `docker compose logs gateway` emits plain text, not JSON: sample 20 lines and
assert none parse as a JSON object.
> This test class is **expected to be deleted at the start of Phase 2**, not
> maintained. Say so in its module docstring.
### 5.10 G11 — frontend (`contract`)
- `GET /` returns 200, `text/html`, and references `app.js` and `style.css`.
- Those two assets return 200 with sensible content types.
- `GET /api/accounts` returns ≥ 100 accounts, each with `id`, `holder_name`,
`currency`, `balance_minor`.
No browser automation. Serving and shape only.
---
## 6. G6 — pathology tests
**One test per flag.** Each follows the same skeleton:
```
1. set chaos-clean
2. warm up (discard)
3. measure → baseline
4. enable exactly one flag via the API
5. warm up (discard)
6. measure → degraded
7. assert the ratio, with the failure message printing both measurements
8. teardown: chaos-clean
```
Measurement runs the median of 3 for latency-based assertions. Failure messages
must always include both raw numbers — a bare `assert ratio > 3` is useless at
2 a.m.
| § | Flag | Primary signal (hard to fake) | Secondary signal | Assertion |
|---|---|---|---|---|
| 6.1 | `slow_sanctions` | — | p99/p50 of `POST /api/transactions` | degraded p99/p50 ≥ **6**, baseline p99/p50 ≤ 4; degraded p99 ≥ 5× baseline p99 |
| 6.2 | `sanctions_errors` | count of 502/504 responses over 300 requests | — | degraded error rate ∈ [1%, 10%]; baseline == 0 |
| 6.3 | `n_plus_one` | `pg_stat_statements` call delta across **one** insights request | latency ratio | degraded ≥ **50** statements; baseline ≤ 10; latency ratio ≥ 2 |
| 6.4 | *missing index* (`make drop-index` / `fix-index`, not a chaos flag) | `EXPLAIN (ANALYZE, FORMAT JSON)` on the insights aggregation contains a `Seq Scan` on `transactions`; after `fix-index` it contains an `Index Scan`/`Bitmap Index Scan` | latency ratio | plan node types as stated; unindexed latency ≥ **3×** indexed. Teardown restores the dropped state. |
| 6.5 | `pool_starvation` | throughput (req/s) at concurrency 5 vs 25 | p95 ratio | with the flag on, throughput at c=25 is ≤ **1.4×** throughput at c=5 (it plateaus); with it off, ≥ 2.5×. Also degraded p95 at c=25 ≥ 3× baseline p95 at c=25 |
| 6.6 | `row_lock_contention` | 25 concurrent transactions against **one hot account** vs 25 spread across 25 cold accounts, both with the flag on | — | single-hot-account p95 ≥ **3×** spread p95; with the flag off the same comparison is ≤ 1.8× |
| 6.7 | `blocking_event_loop` | wall-clock for 10 concurrent requests ÷ wall-clock for 1 request | — | degraded ratio ≥ **6** (serialised); baseline ratio ≤ 2.5 (concurrent) |
| 6.8 | `retry_storm` | count of `sanctions` container log lines matching a lookup, per transaction, with `sanctions_errors` also on | — | degraded ≥ **2.0** lookups/transaction; baseline (retry off, errors on) ≤ 1.2. *Log-line counting is the only cross-service call counter available at Phase 0 — see the note below.* |
| 6.9 | `no_batching` | `sanctions` lookup log lines per transaction, `sanctions_errors` **off** | latency ratio | degraded ≥ **2.0** lookups/transaction; baseline ≈ 1.0 (≤ 1.2); latency ratio ≥ 1.5 |
| 6.10 | `unbounded_cache` (`slow`) | `insights` container RSS via `docker stats` before vs after 2000 requests with **distinct** query parameters, sampled 30 s after the burst ends | — | degraded RSS growth ≥ **20 MB** and still elevated at +30 s; baseline growth ≤ 8 MB |
| 6.11 | `slow_consumer` | `XINFO GROUPS stream:transactions` → `lag`/`pending` for the `notifier` group after a 150-transaction burst | drain time | degraded lag ≥ **30** at burst end and still > 0 after 10 s; baseline drains to 0 within 10 s |
| 6.12 | `cold_cache` (`slow`) | same insights request at 1 rps for 150 s; count samples > 3× the median | — | degraded ≥ **2** spikes (a 60 s TTL over 150 s); baseline ≤ 1 |
| 6.13 | `poison_data` | request against each `poison_account_ids` entry | — | degraded produces ≥ 1 response with status **500**; baseline produces zero 500s across the same requests (4xx or 200 only) |
| 6.14 | `chatty_health` | `/healthz` hits on downstream containers during a single transaction, counted from logs | — | degraded ≥ **5** healthz lines across downstreams per transaction; baseline == 0 |
**Note on log-line counting (6.8, 6.9, 6.14).** At Phase 0 the plain-text log is
the only cross-service observability surface that exists — which is precisely the
deficiency this whole project sets out to fix. Implement these helpers behind
`harness/containers.py::count_log_lines(service, pattern, since)` so there is
exactly one place to change. **From Phase 3 these assertions should be rewritten
against structured logs, and from Phase 4 against span counts** — at which point
they become both simpler and more robust. Note this in `tests/README.md`; it is a
deliberate teaching artefact, not a shortcut.
**Anti-fake note for Agent B.** 6.3, 6.4, 6.10, 6.11 and 6.13 cannot be satisfied
by inserting a `sleep()`: they require, respectively, real repeated queries, a
real missing index, real unbounded allocation, real consumer slowness, and a real
unhandled exception path. The timing-only assertions (6.1, 6.5–6.7, 6.9, 6.12)
are stated as ratios for the same reason — passing them by faking is more work
than implementing the mechanism described in the Phase 0 spec.
---
## 7. The measurement harness
`harness/load.py` provides one primitive:
```python
async def drive(
method: str, url_factory: Callable[[], tuple[str, dict]],
total: int, concurrency: int,
warmup: int = 20, timeout: float = 15.0,
) -> LatencyReport
```
`LatencyReport` carries `count, ok, errors, error_rate, status_counts,
p50, p95, p99, max, throughput_rps, wall_seconds`, plus a `__str__` suitable for
dropping straight into an assertion message.
Rules:
- Warm-up requests are issued and discarded before timing starts.
- Percentiles from the raw sorted sample, nearest-rank. No interpolation, no
averaging of percentiles across runs — **median of the p95s**, never the mean.
- A closed-loop driver (fixed concurrency) is used deliberately for its
simplicity here. Note in `tests/README.md` that this under-measures latency
under saturation (coordinated omission) and that Phase 6 replaces it with k6's
arrival-rate executors. Don't try to fix it in the test suite.
- `measure_median(n=3, fn)` helper for the pathology tests.
`harness/pg.py`:
```python
@contextmanager
def statement_count(dsn) -> Counter # resets pg_stat_statements, yields, returns delta
def explain(dsn, sql, params) -> dict # EXPLAIN (ANALYZE, FORMAT JSON)
def plan_node_types(plan: dict) -> set[str]
```
`harness/streams.py`: `group_lag(group)`, `wait_for_drain(group, timeout)`.
`harness/containers.py`: `rss_mb(service)`, `count_log_lines(service, pattern,
since)`, `stop(service)`, `start(service)`, `wait_ready(service, timeout)`.
---
## 8. Fixtures (`conftest.py`)
| Fixture | Scope | Purpose |
|---|---|---|
| `stack_up` | session | Assert the Compose stack is running and all six services are ready; **fail fast with a clear message** ("run `make up && make seed` first") rather than timing out |
| `seeded` | session | Assert the seed manifest exists and counts are non-zero; fail fast otherwise |
| `manifest` | session | Parsed seed manifest |
| `api` | function | `httpx.AsyncClient` against the gateway, 15 s timeout |
| `dsn` | session | Postgres DSN from env |
| `redis_client` | session | — |
| `chaos` | function | `chaos.set_profile(name)`, `chaos.set_flag(name, bool)`, `chaos.get()`. **Autouse teardown resets to `clean`**, even on failure |
| `fresh_balances` | function | Snapshot and restore the balances of accounts a test touches, so balance assertions don't drift across runs |
Every environment value (ports, DSN, Redis URL) comes from env with the Compose
defaults as fallbacks. No hardcoded values buried in test bodies.
---
## 9. `tests/README.md` — what Agent A must write
- How to run each marker set, and the prerequisite (`make up && make seed`).
- The measurement methodology and why ratios are used rather than absolutes.
- The known-flaky list: 6.10 and 6.12 are the least stable; document the retry
strategy and what a genuine failure looks like versus noise.
- The coordinated-omission caveat on the closed-loop driver.
- The Phase 3/4 migration note for the log-counting helpers.
- An explicit statement that `test_no_observability.py` is deleted at Phase 2.
---
## 10. Acceptance criteria
For **Agent A**:
1. `pytest --collect-only` succeeds and every test carries exactly one goal ID in
its docstring and at least one marker.
2. `grep -rn "from services\|from common\|import services\|import common" tests/`
returns nothing.
3. Every §5 and §6 item has a corresponding test; `tests/README.md` contains the
goal-to-test table.
4. The suite is **red** against an empty repo, and `tests/RED-BASELINE.md`
records the failure reason per test. No test passes vacuously.
5. `make test-contract` completes (failing) in under 60 s.
For **Agent B**:
1. `make test` green three consecutive runs.
2. `make test-slow` green (may be run once).
3. `git diff --stat tests/` against Agent A's commit is empty.
4. `docs/PATHOLOGIES.md` written, and not summarised in the final response.