385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""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
|