Files
orion/app/modules/loyalty/tests/integration/test_merchant_api.py
Samir Boulahtit efca9734d2
Some checks failed
CI / ruff (push) Successful in 12s
CI / pytest (push) Failing after 49m29s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
test(loyalty): add integration and unit tests for analytics, pages, and stats
- Add merchant stats API tests (GET /merchants/loyalty/stats) with 7 test cases
- Add merchant page route tests (program, program-edit, analytics) with 6 test cases
- Add store page route tests (terminal, cards, card-detail, program, program-edit, analytics, enroll) with 16 test cases
- Add unit tests for get_merchant_stats() enhanced fields (new_this_month, estimated_liability_cents, location breakdown) with 6 test cases
- Add unit tests for get_platform_stats() enhanced fields (total_points_issued/redeemed, total_points_balance, new_this_month, estimated_liability_cents) with 4 test cases
- Total: 38 new tests (174 -> 212 passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:32:06 +01:00

375 lines
13 KiB
Python

# app/modules/loyalty/tests/integration/test_merchant_api.py
"""
Integration tests for merchant loyalty API endpoints.
Tests the merchant program CRUD endpoints at:
/api/v1/merchants/loyalty/*
Authentication: Uses dependency overrides (merch_auth_headers pattern).
"""
import pytest
BASE = "/api/v1/merchants/loyalty"
# ============================================================================
# GET /program
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantGetProgram:
"""Tests for GET /api/v1/merchants/loyalty/program."""
def test_get_program_success(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Returns the merchant's loyalty program."""
response = client.get(
f"{BASE}/program", headers=loyalty_merchant_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == loyalty_store_setup["program"].id
assert data["merchant_id"] == loyalty_store_setup["merchant"].id
assert data["points_per_euro"] == 10
assert data["is_active"] is True
def test_get_program_includes_display_fields(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Response includes computed display fields."""
response = client.get(
f"{BASE}/program", headers=loyalty_merchant_headers
)
assert response.status_code == 200
data = response.json()
assert "display_name" in data
assert "is_stamps_enabled" in data
assert "is_points_enabled" in data
def test_get_program_not_found(
self, client, loyalty_merchant_headers_no_program
):
"""Returns 404 when merchant has no program."""
response = client.get(
f"{BASE}/program", headers=loyalty_merchant_headers_no_program
)
assert response.status_code == 404
# ============================================================================
# POST /program
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantCreateProgram:
"""Tests for POST /api/v1/merchants/loyalty/program."""
def test_create_program_success(
self, client, loyalty_merchant_headers_no_program, loyalty_merchant_setup
):
"""Create a new loyalty program for the merchant."""
response = client.post(
f"{BASE}/program",
json={
"loyalty_type": "points",
"points_per_euro": 5,
"card_name": "My Rewards",
"card_color": "#FF5733",
},
headers=loyalty_merchant_headers_no_program,
)
assert response.status_code == 201
data = response.json()
assert data["loyalty_type"] == "points"
assert data["points_per_euro"] == 5
assert data["card_name"] == "My Rewards"
assert data["card_color"] == "#FF5733"
assert data["merchant_id"] == loyalty_merchant_setup["merchant"].id
def test_create_program_with_rewards(
self, client, loyalty_merchant_headers_no_program
):
"""Create program with point rewards configured."""
response = client.post(
f"{BASE}/program",
json={
"loyalty_type": "points",
"points_per_euro": 10,
"points_rewards": [
{
"id": "r1",
"name": "5 EUR off",
"points_required": 100,
"is_active": True,
},
{
"id": "r2",
"name": "10 EUR off",
"points_required": 200,
"is_active": True,
},
],
},
headers=loyalty_merchant_headers_no_program,
)
assert response.status_code == 201
data = response.json()
assert len(data["points_rewards"]) == 2
assert data["points_rewards"][0]["name"] == "5 EUR off"
def test_create_program_defaults(
self, client, loyalty_merchant_headers_no_program
):
"""Creating with minimal data uses sensible defaults."""
response = client.post(
f"{BASE}/program",
json={"loyalty_type": "points"},
headers=loyalty_merchant_headers_no_program,
)
assert response.status_code == 201
data = response.json()
assert data["points_per_euro"] == 1
assert data["is_active"] is True
def test_create_program_duplicate_fails(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Cannot create a second program for the same merchant."""
response = client.post(
f"{BASE}/program",
json={"loyalty_type": "points"},
headers=loyalty_merchant_headers,
)
# Should fail - merchant already has a program
assert response.status_code in (400, 409, 422)
# ============================================================================
# PATCH /program
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantUpdateProgram:
"""Tests for PATCH /api/v1/merchants/loyalty/program."""
def test_update_program_success(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Update program fields."""
response = client.patch(
f"{BASE}/program",
json={
"points_per_euro": 20,
"card_name": "Updated Rewards",
},
headers=loyalty_merchant_headers,
)
assert response.status_code == 200
data = response.json()
assert data["points_per_euro"] == 20
assert data["card_name"] == "Updated Rewards"
def test_update_program_partial(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Partial update only modifies specified fields."""
response = client.patch(
f"{BASE}/program",
json={"card_name": "New Name Only"},
headers=loyalty_merchant_headers,
)
assert response.status_code == 200
data = response.json()
assert data["card_name"] == "New Name Only"
# Other fields should be unchanged
assert data["points_per_euro"] == 10
def test_update_program_deactivate(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Can deactivate program via update."""
response = client.patch(
f"{BASE}/program",
json={"is_active": False},
headers=loyalty_merchant_headers,
)
assert response.status_code == 200
assert response.json()["is_active"] is False
def test_update_program_not_found(
self, client, loyalty_merchant_headers_no_program
):
"""Update returns 404 when no program exists."""
response = client.patch(
f"{BASE}/program",
json={"card_name": "No Program"},
headers=loyalty_merchant_headers_no_program,
)
assert response.status_code == 404
# ============================================================================
# DELETE /program
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantDeleteProgram:
"""Tests for DELETE /api/v1/merchants/loyalty/program."""
def test_delete_program_success(
self, client, loyalty_merchant_headers, loyalty_store_setup, db
):
"""Delete the merchant's loyalty program."""
program_id = loyalty_store_setup["program"].id
response = client.delete(
f"{BASE}/program", headers=loyalty_merchant_headers
)
assert response.status_code == 204
# Verify program is deleted
from app.modules.loyalty.models import LoyaltyProgram
deleted = db.get(LoyaltyProgram, program_id)
assert deleted is None
def test_delete_program_not_found(
self, client, loyalty_merchant_headers_no_program
):
"""Delete returns 404 when no program exists."""
response = client.delete(
f"{BASE}/program", headers=loyalty_merchant_headers_no_program
)
assert response.status_code == 404
def test_delete_then_get_returns_404(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""After deletion, GET returns 404."""
client.delete(f"{BASE}/program", headers=loyalty_merchant_headers)
response = client.get(
f"{BASE}/program", headers=loyalty_merchant_headers
)
assert response.status_code == 404
# ============================================================================
# GET /stats
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantGetStats:
"""Tests for GET /api/v1/merchants/loyalty/stats."""
def test_get_stats_success(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Returns merchant-wide loyalty statistics."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers
)
assert response.status_code == 200
data = response.json()
assert data["merchant_id"] == loyalty_store_setup["merchant"].id
assert data["program_id"] == loyalty_store_setup["program"].id
def test_get_stats_includes_card_counts(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Stats include card counts from existing setup."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers
)
data = response.json()
# loyalty_store_setup creates 1 card
assert data["total_cards"] == 1
assert data["active_cards"] == 1
def test_get_stats_includes_all_fields(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Response includes all expected stat fields."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers
)
data = response.json()
expected_fields = [
"merchant_id",
"program_id",
"total_cards",
"active_cards",
"new_this_month",
"total_points_issued",
"total_points_redeemed",
"points_issued_30d",
"points_redeemed_30d",
"transactions_30d",
"estimated_liability_cents",
"program",
"locations",
]
for field in expected_fields:
assert field in data, f"Missing field: {field}"
def test_get_stats_includes_program_info(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Stats include program configuration details."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers
)
data = response.json()
assert data["program"] is not None
assert data["program"]["loyalty_type"] == "points"
assert data["program"]["points_per_euro"] == 10
def test_get_stats_includes_locations(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
"""Stats include per-location breakdown."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers
)
data = response.json()
assert isinstance(data["locations"], list)
# loyalty_store_setup creates 1 store
assert len(data["locations"]) >= 1
loc = data["locations"][0]
assert "store_id" in loc
assert "store_name" in loc
assert "enrolled_count" in loc
assert "points_earned" in loc
assert "points_redeemed" in loc
assert "transactions_30d" in loc
def test_get_stats_no_program(
self, client, loyalty_merchant_headers_no_program
):
"""Returns empty stats when merchant has no program."""
response = client.get(
f"{BASE}/stats", headers=loyalty_merchant_headers_no_program
)
assert response.status_code == 200
data = response.json()
assert data["program_id"] is None
assert data["total_cards"] == 0
assert data["program"] is None
assert data["locations"] == []