"""Phase 7.5 — Referral 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, referrer_bonus: int = 50, referee_bonus: int = 20, max_referrals: int | None = None, enroll_referee: bool = False, ) -> dict: program = await client.post( "/api/v1/programs", json={"code": f"ref{suffix}", "name": f"Referral {suffix}"}, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201, program.text program_id = program.json()["id"] referrer = await client.post( "/api/v1/members", json={"program_id": program_id, "display_name": "Referrer", "enroll": True}, headers=tenant_headers(TENANT_A), ) assert referrer.status_code == 201, referrer.text referrer_id = referrer.json()["id"] referrer_account = await client.post( "/api/v1/point-accounts", json={"program_id": program_id, "member_id": referrer_id}, headers=tenant_headers(TENANT_A), ) assert referrer_account.status_code == 201, referrer_account.text referrer_account_id = referrer_account.json()["id"] referee = await client.post( "/api/v1/members", json={ "program_id": program_id, "display_name": "Referee", "enroll": enroll_referee, }, headers=tenant_headers(TENANT_A), ) assert referee.status_code == 201, referee.text referee_id = referee.json()["id"] referee_account = await client.post( "/api/v1/point-accounts", json={"program_id": program_id, "member_id": referee_id}, headers=tenant_headers(TENANT_A), ) assert referee_account.status_code == 201, referee_account.text referee_account_id = referee_account.json()["id"] referral_program = await client.post( "/api/v1/referral-programs", json={ "program_id": program_id, "code": f"rp{suffix}", "name": f"Referral Program {suffix}", "status": "active", "referrer_bonus_points": referrer_bonus, "referee_bonus_points": referee_bonus, "max_referrals_per_member": max_referrals, }, headers=tenant_headers(TENANT_A), ) assert referral_program.status_code == 201, referral_program.text referral_program_id = referral_program.json()["id"] code_resp = await client.post( "/api/v1/referral-codes", json={"referral_program_id": referral_program_id, "member_id": referrer_id}, headers=tenant_headers(TENANT_A), ) assert code_resp.status_code == 201, code_resp.text return { "program_id": program_id, "referral_program_id": referral_program_id, "referrer_id": referrer_id, "referrer_account_id": referrer_account_id, "referee_id": referee_id, "referee_account_id": referee_account_id, "code": code_resp.json()["code"], "code_id": code_resp.json()["id"], } async def _attribute(client, ctx, **overrides): payload = {"code": ctx["code"], "referee_member_id": ctx["referee_id"]} payload.update(overrides) return await client.post( "/api/v1/referrals/attribute", json=payload, headers=tenant_headers(TENANT_A) ) @pytest.mark.asyncio async def test_referral_program_crud(client): program = await client.post( "/api/v1/programs", json={"code": "refcrud", "name": "Referral CRUD Program"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] created = await client.post( "/api/v1/referral-programs", json={ "program_id": program_id, "code": "rpcrud", "name": "CRUD", "referrer_bonus_points": 10, "referee_bonus_points": 5, }, headers=tenant_headers(TENANT_A), ) assert created.status_code == 201, created.text assert created.json()["status"] == "draft" rp_id = created.json()["id"] listed = await client.get( "/api/v1/referral-programs", headers=tenant_headers(TENANT_A) ) assert listed.status_code == 200 assert any(p["id"] == rp_id for p in listed.json()) fetched = await client.get( f"/api/v1/referral-programs/{rp_id}", headers=tenant_headers(TENANT_A) ) assert fetched.status_code == 200 assert fetched.json()["code"] == "RPCRUD" updated = await client.patch( f"/api/v1/referral-programs/{rp_id}", json={"status": "active", "referrer_bonus_points": 25}, headers=tenant_headers(TENANT_A), ) assert updated.status_code == 200, updated.text assert updated.json()["status"] == "active" assert updated.json()["referrer_bonus_points"] == 25 deleted = await client.post( f"/api/v1/referral-programs/{rp_id}/delete", headers=tenant_headers(TENANT_A) ) assert deleted.status_code == 200 assert deleted.json()["is_deleted"] is True events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.REFERRAL_PROGRAM_CREATED.value in events assert LoyaltyEventType.REFERRAL_PROGRAM_UPDATED.value in events assert LoyaltyEventType.REFERRAL_PROGRAM_DELETED.value in events @pytest.mark.asyncio async def test_issue_code_returns_existing_active_code(client): ctx = await _setup(client, suffix="ic1") second = await client.post( "/api/v1/referral-codes", json={ "referral_program_id": ctx["referral_program_id"], "member_id": ctx["referrer_id"], }, headers=tenant_headers(TENANT_A), ) assert second.status_code == 201, second.text assert second.json()["id"] == ctx["code_id"] events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.REFERRAL_CODE_ISSUED.value in events @pytest.mark.asyncio async def test_full_flow_attribute_convert_reward_with_ledger_balances(client): ctx = await _setup( client, suffix="h1", referrer_bonus=50, referee_bonus=20, enroll_referee=False ) attributed = await _attribute(client, ctx) assert attributed.status_code == 201, attributed.text body = attributed.json() assert body["status"] == "pending" attribution_id = body["id"] # Referee must be active before conversion. enroll = await client.post( f"/api/v1/members/{ctx['referee_id']}/enroll", json={}, headers=tenant_headers(TENANT_A), ) assert enroll.status_code == 200, enroll.text converted = await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) assert converted.status_code == 200, converted.text assert converted.json()["status"] == "converted" assert converted.json()["converted_at"] is not None rewarded = await client.post( f"/api/v1/referrals/{attribution_id}/reward", headers=tenant_headers(TENANT_A), ) assert rewarded.status_code == 200, rewarded.text reward_body = rewarded.json() assert reward_body["status"] == "rewarded" assert reward_body["referrer_ledger_entry_id"] is not None assert reward_body["referee_ledger_entry_id"] is not None referrer_balance = await client.get( f"/api/v1/point-accounts/{ctx['referrer_account_id']}/balance", headers=tenant_headers(TENANT_A), ) assert referrer_balance.json()["balance"] == 50 referee_balance = await client.get( f"/api/v1/point-accounts/{ctx['referee_account_id']}/balance", headers=tenant_headers(TENANT_A), ) assert referee_balance.json()["balance"] == 20 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.REFERRAL_CODE_ISSUED.value in events assert LoyaltyEventType.REFERRAL_ATTRIBUTED.value in events assert LoyaltyEventType.REFERRAL_CONVERTED.value in events assert LoyaltyEventType.REFERRAL_REWARDED.value in events assert LoyaltyEventType.POINTS_EARNED.value in events @pytest.mark.asyncio async def test_self_referral_rejected(client): ctx = await _setup(client, suffix="sr1") resp = await _attribute(client, ctx, referee_member_id=ctx["referrer_id"]) assert resp.status_code == 422 assert resp.json()["error"]["code"] == "referral_self_referral_forbidden" @pytest.mark.asyncio async def test_referee_already_attributed_rejected(client): ctx = await _setup(client, suffix="dup1") first = await _attribute(client, ctx) assert first.status_code == 201, first.text second = await _attribute(client, ctx) assert second.status_code == 409 assert second.json()["error"]["code"] == "referral_referee_already_attributed" @pytest.mark.asyncio async def test_max_referrals_per_member_enforced(client): ctx = await _setup(client, suffix="max1", max_referrals=1) first = await _attribute(client, ctx) assert first.status_code == 201, first.text # New referee for the same referrer's code. referee2 = await client.post( "/api/v1/members", json={ "program_id": ctx["program_id"], "display_name": "Referee 2", "enroll": True, }, headers=tenant_headers(TENANT_A), ) assert referee2.status_code == 201, referee2.text second = await _attribute(client, ctx, referee_member_id=referee2.json()["id"]) assert second.status_code == 409 assert second.json()["error"]["code"] == "referral_max_referrals_exceeded" @pytest.mark.asyncio async def test_convert_requires_pending_status(client): ctx = await _setup(client, suffix="cv1", enroll_referee=True) attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] first_convert = await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) assert first_convert.status_code == 200, first_convert.text second_convert = await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) assert second_convert.status_code == 409 assert second_convert.json()["error"]["code"] == "referral_not_pending" @pytest.mark.asyncio async def test_convert_requires_referee_active(client): ctx = await _setup(client, suffix="cv2", enroll_referee=False) attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] resp = await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "referral_referee_not_active" @pytest.mark.asyncio async def test_reward_requires_converted_status(client): ctx = await _setup(client, suffix="rw1", enroll_referee=True) attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] resp = await client.post( f"/api/v1/referrals/{attribution_id}/reward", headers=tenant_headers(TENANT_A), ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "referral_not_converted" @pytest.mark.asyncio async def test_double_reward_rejected(client): ctx = await _setup(client, suffix="dr1", enroll_referee=True) attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) first_reward = await client.post( f"/api/v1/referrals/{attribution_id}/reward", headers=tenant_headers(TENANT_A), ) assert first_reward.status_code == 200, first_reward.text second_reward = await client.post( f"/api/v1/referrals/{attribution_id}/reward", headers=tenant_headers(TENANT_A), ) assert second_reward.status_code == 409 assert second_reward.json()["error"]["code"] == "referral_not_converted" referrer_balance = await client.get( f"/api/v1/point-accounts/{ctx['referrer_account_id']}/balance", headers=tenant_headers(TENANT_A), ) assert referrer_balance.json()["balance"] == 50 @pytest.mark.asyncio async def test_attribute_idempotency_returns_same_attribution(client): ctx = await _setup(client, suffix="idem1") first = await _attribute(client, ctx, idempotency_key="ref-idem-1") assert first.status_code == 201, first.text second = await _attribute(client, ctx, idempotency_key="ref-idem-1") assert second.status_code == 201, second.text assert second.json()["id"] == first.json()["id"] @pytest.mark.asyncio async def test_cancel_pending_attribution(client): ctx = await _setup(client, suffix="cn1") attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] cancelled = await client.post( f"/api/v1/referrals/{attribution_id}/cancel", json={"reason": "duplicate signup"}, headers=tenant_headers(TENANT_A), ) assert cancelled.status_code == 200, cancelled.text assert cancelled.json()["status"] == "cancelled" assert cancelled.json()["cancel_reason"] == "duplicate signup" # Cancelling again should be rejected. again = await client.post( f"/api/v1/referrals/{attribution_id}/cancel", json={}, headers=tenant_headers(TENANT_A), ) assert again.status_code == 409 assert again.json()["error"]["code"] == "referral_not_cancellable" events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.REFERRAL_CANCELLED.value in events @pytest.mark.asyncio async def test_rewarded_attribution_cannot_be_cancelled(client): ctx = await _setup(client, suffix="cn2", enroll_referee=True) attributed = await _attribute(client, ctx) attribution_id = attributed.json()["id"] await client.post( f"/api/v1/referrals/{attribution_id}/convert", headers=tenant_headers(TENANT_A), ) await client.post( f"/api/v1/referrals/{attribution_id}/reward", headers=tenant_headers(TENANT_A), ) resp = await client.post( f"/api/v1/referrals/{attribution_id}/cancel", json={}, headers=tenant_headers(TENANT_A), ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "referral_not_cancellable" @pytest.mark.asyncio async def test_list_referral_attributions(client): ctx = await _setup(client, suffix="ls1") attributed = await _attribute(client, ctx) assert attributed.status_code == 201, attributed.text listed = await client.get( "/api/v1/referrals", params={"referral_program_id": ctx["referral_program_id"]}, headers=tenant_headers(TENANT_A), ) assert listed.status_code == 200, listed.text assert listed.json()["total"] >= 1 assert listed.json()["items"][0]["referral_program_id"] == ctx["referral_program_id"] @pytest.mark.asyncio async def test_attribute_rejects_inactive_referral_program(client): ctx = await _setup(client, suffix="ia1") deactivated = await client.patch( f"/api/v1/referral-programs/{ctx['referral_program_id']}", json={"status": "inactive"}, headers=tenant_headers(TENANT_A), ) assert deactivated.status_code == 200, deactivated.text resp = await _attribute(client, ctx) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "referral_program_not_active" @pytest.mark.asyncio async def test_revoked_code_cannot_be_used(client): ctx = await _setup(client, suffix="rc1") revoked = await client.post( f"/api/v1/referral-codes/{ctx['code_id']}/revoke", headers=tenant_headers(TENANT_A), ) assert revoked.status_code == 200 assert revoked.json()["status"] == "revoked" resp = await _attribute(client, ctx) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "referral_code_not_active"