Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
6.9 KiB
Python
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
|