340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""Phase 7.6 — Wallet Engine tests."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.events.publisher import get_event_publisher
|
|
from app.events.types import LoyaltyEventType
|
|
from app.tests.conftest import TENANT_A, tenant_headers
|
|
|
|
|
|
async def _open_wallet(client, *, suffix: str, currency: str = "IRR") -> dict:
|
|
program = await client.post(
|
|
"/api/v1/programs",
|
|
json={"code": f"wal{suffix}", "name": f"Wallet {suffix}"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert program.status_code == 201, program.text
|
|
program_id = program.json()["id"]
|
|
|
|
member = await client.post(
|
|
"/api/v1/members",
|
|
json={"program_id": program_id, "display_name": f"Member {suffix}", "enroll": True},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert member.status_code == 201, member.text
|
|
member_id = member.json()["id"]
|
|
|
|
wallet = await client.post(
|
|
"/api/v1/wallets",
|
|
json={
|
|
"program_id": program_id,
|
|
"member_id": member_id,
|
|
"currency_code": currency,
|
|
},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert wallet.status_code == 201, wallet.text
|
|
return {
|
|
"program_id": program_id,
|
|
"member_id": member_id,
|
|
"wallet_id": wallet.json()["id"],
|
|
"account_number": wallet.json()["account_number"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_credit_and_balance(client):
|
|
ctx = await _open_wallet(client, suffix="oc1")
|
|
|
|
credit = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert credit.status_code == 200, credit.text
|
|
assert credit.json()["balance_after"] == 1000
|
|
assert credit.json()["entry_type"] == "credit"
|
|
|
|
again = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert again.status_code == 200
|
|
assert again.json()["id"] == credit.json()["id"]
|
|
|
|
balance = await client.get(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/balance",
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert balance.status_code == 200
|
|
assert balance.json()["balance"] == 1000
|
|
assert balance.json()["currency_code"] == "IRR"
|
|
|
|
events = {e.event_type for e in get_event_publisher().published}
|
|
assert LoyaltyEventType.WALLET_OPENED.value in events
|
|
assert LoyaltyEventType.WALLET_CREDITED.value in events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_debit_insufficient_funds_rejected(client):
|
|
ctx = await _open_wallet(client, suffix="db1")
|
|
await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 200},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
debit = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/debit",
|
|
json={"amount": 50, "reason": "purchase"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert debit.status_code == 200, debit.text
|
|
assert debit.json()["balance_after"] == 150
|
|
|
|
over = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/debit",
|
|
json={"amount": 1000},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert over.status_code == 409
|
|
assert over.json()["error"]["code"] == "wallet_insufficient_funds"
|
|
|
|
events = {e.event_type for e in get_event_publisher().published}
|
|
assert LoyaltyEventType.WALLET_DEBITED.value in events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adjust_and_ledger_list(client):
|
|
ctx = await _open_wallet(client, suffix="adj1")
|
|
await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 500},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
adjust = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/adjust",
|
|
json={"amount": -50, "reason": "correction"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert adjust.status_code == 200, adjust.text
|
|
assert adjust.json()["balance_after"] == 450
|
|
|
|
zero_adjust = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/adjust",
|
|
json={"amount": 0, "reason": "noop"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert zero_adjust.status_code == 422
|
|
assert zero_adjust.json()["error"]["code"] == "invalid_wallet_adjust_amount"
|
|
|
|
page = await client.get(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/ledger",
|
|
params={"limit": 10},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert page.status_code == 200
|
|
body = page.json()
|
|
assert body["total"] >= 2
|
|
assert len(body["items"]) >= 2
|
|
|
|
events = {e.event_type for e in get_event_publisher().published}
|
|
assert LoyaltyEventType.WALLET_ADJUSTED.value in events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_between_wallets_same_currency(client):
|
|
source = await _open_wallet(client, suffix="tr1a")
|
|
target = await _open_wallet(client, suffix="tr1b")
|
|
|
|
await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/credit",
|
|
json={"amount": 300},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
transfer = await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/transfer",
|
|
json={"target_wallet_id": target["wallet_id"], "amount": 120, "reason": "p2p"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert transfer.status_code == 200, transfer.text
|
|
body = transfer.json()
|
|
assert body["source_entry"]["entry_type"] == "transfer_out"
|
|
assert body["source_entry"]["amount"] == -120
|
|
assert body["source_entry"]["balance_after"] == 180
|
|
assert body["target_entry"]["entry_type"] == "transfer_in"
|
|
assert body["target_entry"]["amount"] == 120
|
|
assert body["target_entry"]["balance_after"] == 120
|
|
|
|
source_balance = await client.get(
|
|
f"/api/v1/wallets/{source['wallet_id']}/balance",
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert source_balance.json()["balance"] == 180
|
|
|
|
target_balance = await client.get(
|
|
f"/api/v1/wallets/{target['wallet_id']}/balance",
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert target_balance.json()["balance"] == 120
|
|
|
|
events = {e.event_type for e in get_event_publisher().published}
|
|
assert LoyaltyEventType.WALLET_TRANSFERRED.value in events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_rejects_different_currency(client):
|
|
source = await _open_wallet(client, suffix="cur1a", currency="IRR")
|
|
target = await _open_wallet(client, suffix="cur1b", currency="USD")
|
|
await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/credit",
|
|
json={"amount": 500},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/transfer",
|
|
json={"target_wallet_id": target["wallet_id"], "amount": 10},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert resp.status_code == 422
|
|
assert resp.json()["error"]["code"] == "wallet_currency_mismatch"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_rejects_same_wallet(client):
|
|
ctx = await _open_wallet(client, suffix="same1")
|
|
await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 500},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/transfer",
|
|
json={"target_wallet_id": ctx["wallet_id"], "amount": 10},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert resp.status_code == 422
|
|
assert resp.json()["error"]["code"] == "wallet_transfer_same_account"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_idempotency_returns_same_entries(client):
|
|
source = await _open_wallet(client, suffix="idem1a")
|
|
target = await _open_wallet(client, suffix="idem1b")
|
|
await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/credit",
|
|
json={"amount": 500},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
first = await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/transfer",
|
|
json={
|
|
"target_wallet_id": target["wallet_id"],
|
|
"amount": 75,
|
|
"idempotency_key": "tr-idem-1",
|
|
},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
|
|
second = await client.post(
|
|
f"/api/v1/wallets/{source['wallet_id']}/transfer",
|
|
json={
|
|
"target_wallet_id": target["wallet_id"],
|
|
"amount": 75,
|
|
"idempotency_key": "tr-idem-1",
|
|
},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["source_entry"]["id"] == first.json()["source_entry"]["id"]
|
|
assert second.json()["target_entry"]["id"] == first.json()["target_entry"]["id"]
|
|
|
|
balance = await client.get(
|
|
f"/api/v1/wallets/{source['wallet_id']}/balance",
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert balance.json()["balance"] == 425
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_frozen_wallet_rejects_mutations(client):
|
|
ctx = await _open_wallet(client, suffix="frz1")
|
|
await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 200},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
frozen = await client.patch(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}",
|
|
json={"status": "frozen"},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert frozen.status_code == 200, frozen.text
|
|
assert frozen.json()["status"] == "frozen"
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 10},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert resp.status_code == 409
|
|
assert resp.json()["error"]["code"] == "wallet_account_frozen"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_wallet_rejects_mutations(client):
|
|
ctx = await _open_wallet(client, suffix="cls1")
|
|
await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/credit",
|
|
json={"amount": 200},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
|
|
closed = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/delete",
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert closed.status_code == 200, closed.text
|
|
assert closed.json()["status"] == "closed"
|
|
assert closed.json()["is_deleted"] is True
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/wallets/{ctx['wallet_id']}/debit",
|
|
json={"amount": 10},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
events = {e.event_type for e in get_event_publisher().published}
|
|
assert LoyaltyEventType.WALLET_CLOSED.value in events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_wallet_for_member_program_rejected(client):
|
|
ctx = await _open_wallet(client, suffix="dup1")
|
|
|
|
dup = await client.post(
|
|
"/api/v1/wallets",
|
|
json={"program_id": ctx["program_id"], "member_id": ctx["member_id"]},
|
|
headers=tenant_headers(TENANT_A),
|
|
)
|
|
assert dup.status_code == 409
|
|
assert dup.json()["error"]["code"] == "wallet_account_exists"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_wallets(client):
|
|
ctx = await _open_wallet(client, suffix="ls1")
|
|
|
|
listed = await client.get("/api/v1/wallets", headers=tenant_headers(TENANT_A))
|
|
assert listed.status_code == 200
|
|
assert any(w["id"] == ctx["wallet_id"] for w in listed.json())
|