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

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"