TorbatYar/backend/services/loyalty/app/tests/test_phase73.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

360 lines
12 KiB
Python

"""Phase 7.3 — Rewards 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 _setup(
client,
*,
suffix: str,
points_cost: int = 50,
stock_limit: int | None = None,
max_per_member: int | None = None,
earn_amount: int = 100,
) -> dict:
program = await client.post(
"/api/v1/programs",
json={"code": f"rwd{suffix}", "name": f"Rewards {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": "Member", "enroll": True},
headers=tenant_headers(TENANT_A),
)
assert member.status_code == 201, member.text
member_id = member.json()["id"]
account = await client.post(
"/api/v1/point-accounts",
json={"program_id": program_id, "member_id": member_id},
headers=tenant_headers(TENANT_A),
)
assert account.status_code == 201, account.text
account_id = account.json()["id"]
if earn_amount:
earn = await client.post(
f"/api/v1/point-accounts/{account_id}/earn",
json={"amount": earn_amount, "reason": "seed"},
headers=tenant_headers(TENANT_A),
)
assert earn.status_code == 200, earn.text
reward_payload = {
"program_id": program_id,
"code": f"rew{suffix}",
"name": f"Reward {suffix}",
"reward_type": "gift",
"status": "active",
"points_cost": points_cost,
}
if stock_limit is not None:
reward_payload["stock_limit"] = stock_limit
if max_per_member is not None:
reward_payload["max_per_member"] = max_per_member
reward = await client.post(
"/api/v1/rewards", json=reward_payload, headers=tenant_headers(TENANT_A)
)
assert reward.status_code == 201, reward.text
return {
"program_id": program_id,
"member_id": member_id,
"account_id": account_id,
"reward_id": reward.json()["id"],
}
@pytest.mark.asyncio
async def test_redeem_debits_points_and_creates_redemption(client):
ctx = await _setup(client, suffix="r1", points_cost=40, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert redeem.status_code == 201, redeem.text
body = redeem.json()
assert body["status"] == "pending"
assert body["points_spent"] == 40
assert body["voucher_code"]
assert body["ledger_entry_id"] is not None
balance = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance.json()["balance"] == 60
reward = await client.get(
f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A)
)
assert reward.json()["redeemed_count"] == 1
events = {e.event_type for e in get_event_publisher().published}
assert LoyaltyEventType.REWARD_REDEEMED.value in events
assert LoyaltyEventType.POINTS_REDEEMED.value in events
@pytest.mark.asyncio
async def test_redeem_free_reward_skips_ledger_debit(client):
ctx = await _setup(client, suffix="r0", points_cost=0, earn_amount=50)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert redeem.status_code == 201, redeem.text
body = redeem.json()
assert body["points_spent"] == 0
assert body["ledger_entry_id"] is None
balance = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance.json()["balance"] == 50
@pytest.mark.asyncio
async def test_redeem_insufficient_points_rejected(client):
ctx = await _setup(client, suffix="r2", points_cost=500, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert redeem.status_code == 409
assert redeem.json()["error"]["code"] == "insufficient_points"
balance = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance.json()["balance"] == 100
reward = await client.get(
f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A)
)
assert reward.json()["redeemed_count"] == 0
@pytest.mark.asyncio
async def test_redeem_respects_stock_limit(client):
ctx = await _setup(client, suffix="r3", points_cost=10, stock_limit=1, earn_amount=100)
first = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert first.status_code == 201, first.text
second = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert second.status_code == 409
assert second.json()["error"]["code"] == "reward_stock_exhausted"
@pytest.mark.asyncio
async def test_redeem_respects_max_per_member(client):
ctx = await _setup(
client, suffix="r4", points_cost=10, max_per_member=1, earn_amount=100
)
first = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert first.status_code == 201, first.text
second = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert second.status_code == 409
assert second.json()["error"]["code"] == "reward_max_per_member_exceeded"
@pytest.mark.asyncio
async def test_redeem_idempotency_returns_same_redemption(client):
ctx = await _setup(client, suffix="r5", points_cost=10, earn_amount=100)
first = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"], "idempotency_key": "idem-1"},
headers=tenant_headers(TENANT_A),
)
assert first.status_code == 201, first.text
second = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"], "idempotency_key": "idem-1"},
headers=tenant_headers(TENANT_A),
)
assert second.status_code == 201
assert second.json()["id"] == first.json()["id"]
balance = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance.json()["balance"] == 90
@pytest.mark.asyncio
async def test_fulfill_redemption(client):
ctx = await _setup(client, suffix="r6", points_cost=10, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
redemption_id = redeem.json()["id"]
fulfill = await client.post(
f"/api/v1/redemptions/{redemption_id}/fulfill",
headers=tenant_headers(TENANT_A),
)
assert fulfill.status_code == 200, fulfill.text
assert fulfill.json()["status"] == "fulfilled"
assert fulfill.json()["fulfilled_at"] is not None
events = {e.event_type for e in get_event_publisher().published}
assert LoyaltyEventType.REWARD_FULFILLED.value in events
again = await client.post(
f"/api/v1/redemptions/{redemption_id}/fulfill",
headers=tenant_headers(TENANT_A),
)
assert again.status_code == 409
assert again.json()["error"]["code"] == "redemption_not_pending"
@pytest.mark.asyncio
async def test_cancel_redemption_refunds_points(client):
ctx = await _setup(client, suffix="r7", points_cost=30, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
redemption_id = redeem.json()["id"]
balance_before = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance_before.json()["balance"] == 70
cancel = await client.post(
f"/api/v1/redemptions/{redemption_id}/cancel",
json={"reason": "customer request"},
headers=tenant_headers(TENANT_A),
)
assert cancel.status_code == 200, cancel.text
assert cancel.json()["status"] == "cancelled"
assert cancel.json()["cancelled_at"] is not None
assert cancel.json()["cancel_reason"] == "customer request"
balance_after = await client.get(
f"/api/v1/point-accounts/{ctx['account_id']}/balance",
headers=tenant_headers(TENANT_A),
)
assert balance_after.json()["balance"] == 100
reward = await client.get(
f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A)
)
assert reward.json()["redeemed_count"] == 0
events = {e.event_type for e in get_event_publisher().published}
assert LoyaltyEventType.REWARD_REDEMPTION_CANCELLED.value in events
again = await client.post(
f"/api/v1/redemptions/{redemption_id}/cancel",
json={"reason": "again"},
headers=tenant_headers(TENANT_A),
)
assert again.status_code == 409
@pytest.mark.asyncio
async def test_cancel_requires_reason(client):
ctx = await _setup(client, suffix="r7b", points_cost=10, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
redemption_id = redeem.json()["id"]
bad = await client.post(
f"/api/v1/redemptions/{redemption_id}/cancel",
json={"reason": ""},
headers=tenant_headers(TENANT_A),
)
assert bad.status_code == 422
@pytest.mark.asyncio
async def test_inactive_reward_rejected(client):
ctx = await _setup(client, suffix="r8", points_cost=10, earn_amount=100)
patched = await client.patch(
f"/api/v1/rewards/{ctx['reward_id']}",
json={"status": "inactive"},
headers=tenant_headers(TENANT_A),
)
assert patched.status_code == 200, patched.text
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert redeem.status_code == 409
assert redeem.json()["error"]["code"] == "reward_not_active"
@pytest.mark.asyncio
async def test_list_redemptions_for_reward_and_top_level(client):
ctx = await _setup(client, suffix="r9", points_cost=10, earn_amount=100)
redeem = await client.post(
f"/api/v1/rewards/{ctx['reward_id']}/redeem",
json={"member_id": ctx["member_id"]},
headers=tenant_headers(TENANT_A),
)
assert redeem.status_code == 201, redeem.text
listed = await client.get(
f"/api/v1/rewards/{ctx['reward_id']}/redemptions",
headers=tenant_headers(TENANT_A),
)
assert listed.status_code == 200
assert listed.json()["total"] >= 1
top_level = await client.get(
"/api/v1/redemptions",
params={"reward_id": ctx["reward_id"]},
headers=tenant_headers(TENANT_A),
)
assert top_level.status_code == 200
assert top_level.json()["total"] >= 1
fetched = await client.get(
f"/api/v1/redemptions/{redeem.json()['id']}",
headers=tenant_headers(TENANT_A),
)
assert fetched.status_code == 200
assert fetched.json()["id"] == redeem.json()["id"]