Align loyalty pages across admin, merchant, and store personas so each sees the same page set scoped to their access level. Admin acts as a superset of merchant with "on behalf" capabilities. New pages: - Store: Staff PINs management (CRUD) - Merchant: Cards, Card Detail, Transactions, Staff PINs (CRUD), Settings (read-only) - Admin: Merchant Cards, Card Detail, Transactions, PINs (read-only) Architecture: - 4 shared Jinja2 partials (cards-list, card-detail, transactions, pins) - 4 shared JS factory modules parameterized by apiPrefix/scope - Persona templates are thin wrappers including shared partials - PinDetailResponse schema for cross-store PIN listings API: 17 new endpoints (11 merchant, 6 admin on-behalf) Tests: 38 new integration tests, arch-check green i18n: ~130 new keys across en/fr/de/lb Docs: pages-and-navigation.md with full page matrix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
510 lines
17 KiB
Python
510 lines
17 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"] == []
|
|
|
|
|
|
# ============================================================================
|
|
# GET /cards
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.api
|
|
@pytest.mark.loyalty
|
|
class TestMerchantListCards:
|
|
"""Tests for GET /api/v1/merchants/loyalty/cards."""
|
|
|
|
def test_list_cards_success(
|
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
|
):
|
|
"""Returns cards list for the merchant."""
|
|
response = client.get(f"{BASE}/cards", headers=loyalty_merchant_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "cards" in data
|
|
assert "total" in data
|
|
assert isinstance(data["cards"], list)
|
|
|
|
def test_list_cards_requires_auth(self, client):
|
|
"""Unauthenticated request is rejected."""
|
|
response = client.get(f"{BASE}/cards")
|
|
assert response.status_code in [401, 403]
|
|
|
|
|
|
# ============================================================================
|
|
# GET /transactions
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.api
|
|
@pytest.mark.loyalty
|
|
class TestMerchantListTransactions:
|
|
"""Tests for GET /api/v1/merchants/loyalty/transactions."""
|
|
|
|
def test_list_transactions_empty(
|
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
|
):
|
|
"""Returns empty list when no transactions exist."""
|
|
response = client.get(f"{BASE}/transactions", headers=loyalty_merchant_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["transactions"] == []
|
|
assert data["total"] == 0
|
|
|
|
def test_list_transactions_requires_auth(self, client):
|
|
"""Unauthenticated request is rejected."""
|
|
response = client.get(f"{BASE}/transactions")
|
|
assert response.status_code in [401, 403]
|
|
|
|
|
|
# ============================================================================
|
|
# GET /pins
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.api
|
|
@pytest.mark.loyalty
|
|
class TestMerchantListPins:
|
|
"""Tests for GET /api/v1/merchants/loyalty/pins."""
|
|
|
|
def test_list_pins_empty(
|
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
|
):
|
|
"""Returns empty list when no PINs exist."""
|
|
response = client.get(f"{BASE}/pins", headers=loyalty_merchant_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["pins"] == []
|
|
assert data["total"] == 0
|
|
|
|
def test_list_pins_requires_auth(self, client):
|
|
"""Unauthenticated request is rejected."""
|
|
response = client.get(f"{BASE}/pins")
|
|
assert response.status_code in [401, 403]
|
|
|
|
|
|
# ============================================================================
|
|
# GET /settings
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.api
|
|
@pytest.mark.loyalty
|
|
class TestMerchantGetSettings:
|
|
"""Tests for GET /api/v1/merchants/loyalty/settings."""
|
|
|
|
def test_get_settings_success(
|
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
|
):
|
|
"""Returns merchant loyalty settings."""
|
|
response = client.get(f"{BASE}/settings", headers=loyalty_merchant_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "staff_pin_policy" in data
|
|
|
|
def test_get_settings_requires_auth(self, client):
|
|
"""Unauthenticated request is rejected."""
|
|
response = client.get(f"{BASE}/settings")
|
|
assert response.status_code in [401, 403]
|
|
|
|
|
|
# ============================================================================
|
|
# GET /locations
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.api
|
|
@pytest.mark.loyalty
|
|
class TestMerchantGetLocations:
|
|
"""Tests for GET /api/v1/merchants/loyalty/locations."""
|
|
|
|
def test_get_locations_success(
|
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
|
):
|
|
"""Returns merchant's store locations."""
|
|
response = client.get(f"{BASE}/locations", headers=loyalty_merchant_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) >= 1
|
|
|
|
def test_get_locations_requires_auth(self, client):
|
|
"""Unauthenticated request is rejected."""
|
|
response = client.get(f"{BASE}/locations")
|
|
assert response.status_code in [401, 403]
|