"""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