TorbatYar/backend/services/loyalty/app/tests/test_phase71.py
Mortezakoohjani e41ecfad4c Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:23 +03:30

205 lines
6.9 KiB
Python

"""Phase 7.1 — Membership 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_B, tenant_headers
from app.validators.membership import ensure_lifecycle_transition
from app.models.types import MemberStatus, MembershipLifecycleAction
from shared.exceptions import AppError
async def _program(client, code="p71", **extra):
resp = await client.post(
"/api/v1/programs",
json={"code": code, "name": code, **extra},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _member(client, program_id, *, enroll=False, display_name="Ali"):
resp = await client.post(
"/api/v1/members",
json={
"program_id": program_id,
"display_name": display_name,
"enroll": enroll,
},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201, resp.text
return resp.json()
def test_lifecycle_transition_matrix_unit():
ensure_lifecycle_transition(
action=MembershipLifecycleAction.ACTIVATE, current=MemberStatus.PENDING
)
with pytest.raises(AppError) as exc:
ensure_lifecycle_transition(
action=MembershipLifecycleAction.FREEZE, current=MemberStatus.PENDING
)
assert exc.value.error_code == "invalid_lifecycle_transition"
with pytest.raises(AppError) as exc2:
ensure_lifecycle_transition(
action=MembershipLifecycleAction.ACTIVATE, current=MemberStatus.CANCELLED
)
assert exc2.value.error_code == "membership_terminal"
@pytest.mark.asyncio
async def test_activate_freeze_resume_renew_cancel_flow(client):
program = await _program(client, "life1")
member = await _member(client, program["id"], enroll=False)
mid = member["id"]
activated = await client.post(
f"/api/v1/members/{mid}/activate",
json={"term_days": 30},
headers=tenant_headers(TENANT_A),
)
assert activated.status_code == 200, activated.text
body = activated.json()
assert body["status"] == "active"
assert body["activated_at"] is not None
assert body["membership_expires_at"] is not None
frozen = await client.post(
f"/api/v1/members/{mid}/freeze",
json={"reason": "vacation"},
headers=tenant_headers(TENANT_A),
)
assert frozen.status_code == 200
assert frozen.json()["status"] == "frozen"
assert frozen.json()["freeze_reason"] == "vacation"
resumed = await client.post(
f"/api/v1/members/{mid}/resume",
json={},
headers=tenant_headers(TENANT_A),
)
assert resumed.status_code == 200
assert resumed.json()["status"] == "active"
assert resumed.json()["frozen_at"] is None
renewed = await client.post(
f"/api/v1/members/{mid}/renew",
json={"term_days": 60},
headers=tenant_headers(TENANT_A),
)
assert renewed.status_code == 200
assert renewed.json()["status"] == "active"
cancelled = await client.post(
f"/api/v1/members/{mid}/cancel",
json={"reason": "customer request"},
headers=tenant_headers(TENANT_A),
)
assert cancelled.status_code == 200
assert cancelled.json()["status"] == "cancelled"
life = await client.get(
f"/api/v1/members/{mid}/lifecycle",
headers=tenant_headers(TENANT_A),
)
assert life.status_code == 200
actions = {row["action"] for row in life.json()}
assert {"activate", "freeze", "resume", "renew", "cancel"} <= actions
events = {e.event_type for e in get_event_publisher().published}
assert LoyaltyEventType.MEMBER_ACTIVATED.value in events
assert LoyaltyEventType.MEMBER_FROZEN.value in events
assert LoyaltyEventType.MEMBER_CANCELLED.value in events
@pytest.mark.asyncio
async def test_expire_and_reactivate(client):
program = await _program(client, "exp1")
member = await _member(client, program["id"], enroll=True)
mid = member["id"]
expired = await client.post(
f"/api/v1/members/{mid}/expire",
json={},
headers=tenant_headers(TENANT_A),
)
assert expired.status_code == 200
assert expired.json()["status"] == "expired"
reactivated = await client.post(
f"/api/v1/members/{mid}/activate",
json={"term_days": 10},
headers=tenant_headers(TENANT_A),
)
assert reactivated.status_code == 200
assert reactivated.json()["status"] == "active"
assert reactivated.json()["expired_at"] is None
@pytest.mark.asyncio
async def test_transfer_creates_target_member(client):
source_program = await _program(client, "src")
target_program = await _program(client, "dst")
member = await _member(client, source_program["id"], enroll=True)
mid = member["id"]
transferred = await client.post(
f"/api/v1/members/{mid}/transfer",
json={"target_program_id": target_program["id"], "reason": "move"},
headers=tenant_headers(TENANT_A),
)
assert transferred.status_code == 200, transferred.text
assert transferred.json()["status"] == "transferred"
target_id = transferred.json()["transferred_to_member_id"]
assert target_id
target = await client.get(
f"/api/v1/members/{target_id}",
headers=tenant_headers(TENANT_A),
)
assert target.status_code == 200
assert target.json()["program_id"] == target_program["id"]
assert target.json()["status"] == "active"
@pytest.mark.asyncio
async def test_invalid_transition_rejected(client):
program = await _program(client, "bad")
member = await _member(client, program["id"], enroll=False)
mid = member["id"]
bad = await client.post(
f"/api/v1/members/{mid}/freeze",
json={"reason": "nope"},
headers=tenant_headers(TENANT_A),
)
assert bad.status_code == 409
assert bad.json()["error"]["code"] == "invalid_lifecycle_transition"
@pytest.mark.asyncio
async def test_program_delete_blocked_with_active_members(client):
program = await _program(client, "block")
await _member(client, program["id"], enroll=True)
deleted = await client.post(
f"/api/v1/programs/{program['id']}/delete",
headers=tenant_headers(TENANT_A),
)
assert deleted.status_code == 409
assert deleted.json()["error"]["code"] == "program_has_active_members"
@pytest.mark.asyncio
async def test_lifecycle_tenant_isolation(client):
program = await _program(client, "iso")
member = await _member(client, program["id"], enroll=True)
mid = member["id"]
other = await client.get(
f"/api/v1/members/{mid}/lifecycle",
headers=tenant_headers(TENANT_B),
)
assert other.status_code == 404