Files
orion/app/modules/loyalty/tests/integration/test_merchant_api.py
Samir Boulahtit 6b46a78e72
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 45m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat(loyalty): restructure program CRUD by interface
Move program CRUD from store to merchant/admin interfaces.
Store becomes view-only for program config while merchant gets
full CRUD and admin gets override capabilities.

Merchant portal:
- New API endpoints (GET/POST/PATCH/DELETE /program)
- New settings page with create/edit/delete form
- Overview page now has Create/Edit Program buttons
- Settings menu item added to sidebar

Admin portal:
- New CRUD endpoints (create for merchant, update, delete)
- New activate/deactivate program endpoints
- Programs list has edit and toggle buttons per row
- Merchant detail has create/delete/toggle program actions

Store portal:
- Removed POST/PATCH /program endpoints (now read-only)
- Removed settings page route and template
- Terminal, cards, stats, enroll unchanged

Tests: 112 passed (58 new) covering merchant API, admin CRUD,
store endpoint removal, and program service unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:32:20 +01:00

268 lines
8.9 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