"""Loyalty API, service rules, events, and audit tests — Phase 7.0.""" 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 @pytest.mark.asyncio async def test_health(client): resp = await client.get("/health") assert resp.status_code == 200 body = resp.json() assert body["service"] == "loyalty-service" assert body["status"] == "ok" assert body["version"] == "0.7.6.0" @pytest.mark.asyncio async def test_program_member_enroll_point_account_flow(client): program = await client.post( "/api/v1/programs", json={ "code": "default", "name": "Default Loyalty", "status": "active", "is_default": True, }, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201, program.text program_id = program.json()["id"] assert program.json()["code"] == "DEFAULT" tier = await client.post( "/api/v1/tiers", json={ "program_id": program_id, "code": "bronze", "name": "Bronze", "rank": 1, "min_points": 0, }, headers=tenant_headers(TENANT_A), ) assert tier.status_code == 201, tier.text tier_id = tier.json()["id"] member = await client.post( "/api/v1/members", json={ "program_id": program_id, "display_name": "Sara Ahmadi", "email": "sara@example.com", "mobile": "+989121234567", "tier_id": tier_id, "enroll": True, }, headers=tenant_headers(TENANT_A), ) assert member.status_code == 201, member.text member_body = member.json() assert member_body["status"] == "active" assert member_body["enrolled_at"] is not None assert member_body["membership_number"] member_id = member_body["id"] account = await client.post( "/api/v1/point-accounts", json={"program_id": program_id, "member_id": member_id}, headers=tenant_headers(TENANT_A), ) assert account.status_code == 201, account.text assert "balance" not in account.json() events = get_event_publisher().published types = {e.event_type for e in events} assert LoyaltyEventType.PROGRAM_CREATED.value in types assert LoyaltyEventType.TIER_CREATED.value in types assert LoyaltyEventType.MEMBER_CREATED.value in types assert LoyaltyEventType.MEMBER_ENROLLED.value in types assert LoyaltyEventType.POINT_ACCOUNT_OPENED.value in types @pytest.mark.asyncio async def test_reward_and_campaign_with_rule_versioning(client): program = await client.post( "/api/v1/programs", json={"code": "promo", "name": "Promo Program"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] reward = await client.post( "/api/v1/rewards", json={ "program_id": program_id, "code": "free-coffee", "name": "Free Coffee", "reward_type": "gift", "points_cost": 100, "status": "active", }, headers=tenant_headers(TENANT_A), ) assert reward.status_code == 201, reward.text campaign = await client.post( "/api/v1/campaigns", json={ "program_id": program_id, "code": "summer", "name": "Summer Boost", "rules": {"multiplier": 2}, "starts_on": "2026-06-01", "ends_on": "2026-08-31", }, headers=tenant_headers(TENANT_A), ) assert campaign.status_code == 201, campaign.text campaign_id = campaign.json()["id"] assert campaign.json()["rule_version"] == 1 updated = await client.patch( f"/api/v1/campaigns/{campaign_id}", json={"rules": {"multiplier": 3}, "bump_rule_version": True}, headers=tenant_headers(TENANT_A), ) assert updated.status_code == 200, updated.text assert updated.json()["rule_version"] == 2 @pytest.mark.asyncio async def test_optimistic_lock_conflict(client): program = await client.post( "/api/v1/programs", json={"code": "lock", "name": "Lock Program"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] version = program.json()["version"] ok = await client.patch( f"/api/v1/programs/{program_id}", json={"name": "Updated", "version": version}, headers=tenant_headers(TENANT_A), ) assert ok.status_code == 200 assert ok.json()["version"] == version + 1 conflict = await client.patch( f"/api/v1/programs/{program_id}", json={"name": "Stale", "version": version}, headers=tenant_headers(TENANT_A), ) assert conflict.status_code == 409 @pytest.mark.asyncio async def test_soft_delete_releases_code_for_recreate(client): create = await client.post( "/api/v1/programs", json={"code": "reuse", "name": "Reusable"}, headers=tenant_headers(TENANT_A), ) assert create.status_code == 201, create.text program_id = create.json()["id"] deleted = await client.post( f"/api/v1/programs/{program_id}/delete", headers=tenant_headers(TENANT_A), ) assert deleted.status_code == 200 assert deleted.json()["is_deleted"] is True recreate = await client.post( "/api/v1/programs", json={"code": "reuse", "name": "Reusable Again"}, headers=tenant_headers(TENANT_A), ) assert recreate.status_code == 201, recreate.text assert recreate.json()["code"] == "REUSE" @pytest.mark.asyncio async def test_audit_log_and_outbox_recorded(client): from sqlalchemy import select from app.core.database import AsyncSessionLocal from app.models.outbox import OutboxEvent program = await client.post( "/api/v1/programs", json={"code": "audit1", "name": "Audit Program"}, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201, program.text program_id = program.json()["id"] audit = await client.get( "/api/v1/audit", params={"entity_type": "loyalty_program", "entity_id": program_id}, headers=tenant_headers(TENANT_A), ) assert audit.status_code == 200, audit.text assert any(row["action"] == "create" for row in audit.json()) events = get_event_publisher().published assert any(e.event_type == LoyaltyEventType.PROGRAM_CREATED.value for e in events) async with AsyncSessionLocal() as session: rows = (await session.execute(select(OutboxEvent))).scalars().all() assert any( row.event_type == LoyaltyEventType.PROGRAM_CREATED.value and str(row.status).lower().endswith("pending") for row in rows ) @pytest.mark.asyncio async def test_null_required_field_on_update_rejected(client): program = await client.post( "/api/v1/programs", json={"code": "nullu", "name": "Null Update"}, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201 program_id = program.json()["id"] version = program.json()["version"] bad = await client.patch( f"/api/v1/programs/{program_id}", json={"name": None, "version": version}, headers=tenant_headers(TENANT_A), ) assert bad.status_code == 422 assert bad.json()["error"]["code"] == "null_not_allowed" @pytest.mark.asyncio async def test_member_soft_delete_releases_membership_number(client): program = await client.post( "/api/v1/programs", json={"code": "mbrdel", "name": "Member Delete"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] member = await client.post( "/api/v1/members", json={ "program_id": program_id, "display_name": "A", "membership_number": "MBR-REUSE-1", }, headers=tenant_headers(TENANT_A), ) assert member.status_code == 201, member.text member_id = member.json()["id"] deleted = await client.post( f"/api/v1/members/{member_id}/delete", headers=tenant_headers(TENANT_A), ) assert deleted.status_code == 200 recreate = await client.post( "/api/v1/members", json={ "program_id": program_id, "display_name": "B", "membership_number": "MBR-REUSE-1", }, headers=tenant_headers(TENANT_A), ) assert recreate.status_code == 201, recreate.text @pytest.mark.asyncio async def test_member_update_with_tier_persists_audit(client): program = await client.post( "/api/v1/programs", json={"code": "tieru", "name": "Tier Update"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] tier = await client.post( "/api/v1/tiers", json={ "program_id": program_id, "code": "gold", "name": "Gold", "rank": 2, "min_points": 100, }, headers=tenant_headers(TENANT_A), ) tier_id = tier.json()["id"] member = await client.post( "/api/v1/members", json={"program_id": program_id, "display_name": "C"}, headers=tenant_headers(TENANT_A), ) member_id = member.json()["id"] version = member.json()["version"] updated = await client.patch( f"/api/v1/members/{member_id}", json={"tier_id": tier_id, "version": version}, headers=tenant_headers(TENANT_A), ) assert updated.status_code == 200, updated.text audit = await client.get( "/api/v1/audit", params={"entity_type": "member", "entity_id": member_id}, headers=tenant_headers(TENANT_A), ) assert audit.status_code == 200 assert any(row["action"] == "update" for row in audit.json()) @pytest.mark.asyncio async def test_point_account_rejects_balance_extra_field(client): program = await client.post( "/api/v1/programs", json={"code": "bal", "name": "Balance Guard"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] member = await client.post( "/api/v1/members", json={"program_id": program_id, "display_name": "M", "enroll": True}, headers=tenant_headers(TENANT_A), ) member_id = member.json()["id"] bad = await client.post( "/api/v1/point-accounts", json={ "program_id": program_id, "member_id": member_id, "balance": 100, }, headers=tenant_headers(TENANT_A), ) assert bad.status_code == 422 @pytest.mark.asyncio async def test_default_program_exclusivity(client): a = await client.post( "/api/v1/programs", json={"code": "d1", "name": "D1", "is_default": True}, headers=tenant_headers(TENANT_A), ) b = await client.post( "/api/v1/programs", json={"code": "d2", "name": "D2", "is_default": True}, headers=tenant_headers(TENANT_A), ) assert a.status_code == 201 assert b.status_code == 201 listed = await client.get("/api/v1/programs", headers=tenant_headers(TENANT_A)) defaults = [p for p in listed.json() if p["is_default"]] assert len(defaults) == 1 assert defaults[0]["code"] == "D2" @pytest.mark.asyncio async def test_invalid_campaign_dates(client): program = await client.post( "/api/v1/programs", json={"code": "dates", "name": "Dates Program"}, headers=tenant_headers(TENANT_A), ) program_id = program.json()["id"] bad = await client.post( "/api/v1/campaigns", json={ "program_id": program_id, "code": "bad", "name": "Bad Dates", "starts_on": "2026-12-01", "ends_on": "2026-01-01", }, headers=tenant_headers(TENANT_A), ) assert bad.status_code == 422