"""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