"""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"]