360 lines
12 KiB
Python
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"]
|