469 lines
16 KiB
Python
469 lines
16 KiB
Python
"""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"
|