TorbatYar/backend/services/loyalty/app/tests/test_phase76.py
Mortezakoohjani 071c484530 Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:27:22 +03:30

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())