TorbatYar/backend/services/loyalty/app/tests/test_api.py
Mortezakoohjani 071c484530 Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:27:22 +03:30

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