feat(loyalty): restructure program CRUD by interface
Some checks failed
Some checks failed
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>
This commit is contained in:
267
app/modules/loyalty/tests/integration/test_merchant_api.py
Normal file
267
app/modules/loyalty/tests/integration/test_merchant_api.py
Normal file
@@ -0,0 +1,267 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user