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.
119 lines
4.8 KiB
Python
119 lines
4.8 KiB
Python
"""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
|