141 lines
5.5 KiB
Python
141 lines
5.5 KiB
Python
"""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
|