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:
@@ -1,8 +1,18 @@
|
||||
"""Unit tests for ProgramService."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyProgramAlreadyExistsException,
|
||||
LoyaltyProgramNotFoundException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyProgram
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.schemas.program import ProgramCreate, ProgramUpdate
|
||||
from app.modules.loyalty.services.program_service import ProgramService
|
||||
from app.modules.tenancy.models import Merchant, User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +26,329 @@ class TestProgramService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_merchant(db):
|
||||
"""Create a merchant for program service tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"psowner_{uid}@test.com",
|
||||
username=f"psowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"PS Test Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_program(db, ps_merchant):
|
||||
"""Create a program for program service tests."""
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=ps_merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=50,
|
||||
minimum_redemption_points=100,
|
||||
minimum_purchase_cents=0,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="PS Test Rewards",
|
||||
card_color="#4F46E5",
|
||||
is_active=True,
|
||||
points_rewards=[
|
||||
{"id": "reward_1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||
],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
return program
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Read Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestGetProgram:
|
||||
"""Tests for get_program and get_program_by_merchant."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_get_program_by_id(self, db, ps_program):
|
||||
"""Get a program by its ID."""
|
||||
result = self.service.get_program(db, ps_program.id)
|
||||
assert result is not None
|
||||
assert result.id == ps_program.id
|
||||
|
||||
def test_get_program_not_found(self, db):
|
||||
"""Returns None for non-existent program."""
|
||||
result = self.service.get_program(db, 999999)
|
||||
assert result is None
|
||||
|
||||
def test_get_program_by_merchant(self, db, ps_program, ps_merchant):
|
||||
"""Get a program by merchant ID."""
|
||||
result = self.service.get_program_by_merchant(db, ps_merchant.id)
|
||||
assert result is not None
|
||||
assert result.merchant_id == ps_merchant.id
|
||||
|
||||
def test_get_program_by_merchant_not_found(self, db):
|
||||
"""Returns None when merchant has no program."""
|
||||
result = self.service.get_program_by_merchant(db, 999999)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRequireProgram:
|
||||
"""Tests for require_program and require_program_by_merchant."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_require_program_found(self, db, ps_program):
|
||||
"""Returns program when it exists."""
|
||||
result = self.service.require_program(db, ps_program.id)
|
||||
assert result.id == ps_program.id
|
||||
|
||||
def test_require_program_raises_not_found(self, db):
|
||||
"""Raises exception when program doesn't exist."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.require_program(db, 999999)
|
||||
|
||||
def test_require_program_by_merchant_found(self, db, ps_program, ps_merchant):
|
||||
"""Returns program for merchant."""
|
||||
result = self.service.require_program_by_merchant(db, ps_merchant.id)
|
||||
assert result.merchant_id == ps_merchant.id
|
||||
|
||||
def test_require_program_by_merchant_raises_not_found(self, db):
|
||||
"""Raises exception when merchant has no program."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.require_program_by_merchant(db, 999999)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Create Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestCreateProgram:
|
||||
"""Tests for create_program."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_create_program_points(self, db, ps_merchant):
|
||||
"""Create a points-based program."""
|
||||
data = ProgramCreate(
|
||||
loyalty_type="points",
|
||||
points_per_euro=5,
|
||||
card_name="Unit Test Rewards",
|
||||
card_color="#FF0000",
|
||||
)
|
||||
program = self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
assert program.id is not None
|
||||
assert program.merchant_id == ps_merchant.id
|
||||
assert program.loyalty_type == "points"
|
||||
assert program.points_per_euro == 5
|
||||
assert program.card_name == "Unit Test Rewards"
|
||||
assert program.is_active is True
|
||||
|
||||
def test_create_program_stamps(self, db, ps_merchant):
|
||||
"""Create a stamps-based program."""
|
||||
data = ProgramCreate(
|
||||
loyalty_type="stamps",
|
||||
stamps_target=8,
|
||||
stamps_reward_description="Free coffee",
|
||||
)
|
||||
program = self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
assert program.loyalty_type == "stamps"
|
||||
assert program.stamps_target == 8
|
||||
assert program.stamps_reward_description == "Free coffee"
|
||||
|
||||
def test_create_program_with_rewards(self, db, ps_merchant):
|
||||
"""Create program with configured rewards."""
|
||||
from app.modules.loyalty.schemas.program import PointsRewardConfig
|
||||
|
||||
data = ProgramCreate(
|
||||
loyalty_type="points",
|
||||
points_per_euro=10,
|
||||
points_rewards=[
|
||||
PointsRewardConfig(
|
||||
id="r1",
|
||||
name="5 EUR off",
|
||||
points_required=100,
|
||||
is_active=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
program = self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
assert len(program.points_rewards) == 1
|
||||
assert program.points_rewards[0]["name"] == "5 EUR off"
|
||||
|
||||
def test_create_program_duplicate_raises(self, db, ps_program, ps_merchant):
|
||||
"""Cannot create two programs for the same merchant."""
|
||||
data = ProgramCreate(loyalty_type="points")
|
||||
with pytest.raises(LoyaltyProgramAlreadyExistsException):
|
||||
self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
def test_create_program_creates_merchant_settings(self, db, ps_merchant):
|
||||
"""Creating a program also creates merchant loyalty settings."""
|
||||
data = ProgramCreate(loyalty_type="points")
|
||||
self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||
assert settings is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Update Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestUpdateProgram:
|
||||
"""Tests for update_program."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_update_program_fields(self, db, ps_program):
|
||||
"""Update specific fields."""
|
||||
data = ProgramUpdate(points_per_euro=20, card_name="Updated Name")
|
||||
result = self.service.update_program(db, ps_program.id, data)
|
||||
|
||||
assert result.points_per_euro == 20
|
||||
assert result.card_name == "Updated Name"
|
||||
|
||||
def test_update_program_partial(self, db, ps_program):
|
||||
"""Partial update preserves unchanged fields."""
|
||||
original_points = ps_program.points_per_euro
|
||||
data = ProgramUpdate(card_name="Only Name")
|
||||
result = self.service.update_program(db, ps_program.id, data)
|
||||
|
||||
assert result.card_name == "Only Name"
|
||||
assert result.points_per_euro == original_points
|
||||
|
||||
def test_update_nonexistent_raises(self, db):
|
||||
"""Updating non-existent program raises exception."""
|
||||
data = ProgramUpdate(card_name="Ghost")
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.update_program(db, 999999, data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Activate / Deactivate
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestActivateDeactivate:
|
||||
"""Tests for activate_program and deactivate_program."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_deactivate_program(self, db, ps_program):
|
||||
"""Deactivate an active program."""
|
||||
assert ps_program.is_active is True
|
||||
result = self.service.deactivate_program(db, ps_program.id)
|
||||
assert result.is_active is False
|
||||
|
||||
def test_activate_program(self, db, ps_program):
|
||||
"""Activate an inactive program."""
|
||||
ps_program.is_active = False
|
||||
db.commit()
|
||||
|
||||
result = self.service.activate_program(db, ps_program.id)
|
||||
assert result.is_active is True
|
||||
|
||||
def test_activate_nonexistent_raises(self, db):
|
||||
"""Activating non-existent program raises exception."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.activate_program(db, 999999)
|
||||
|
||||
def test_deactivate_nonexistent_raises(self, db):
|
||||
"""Deactivating non-existent program raises exception."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.deactivate_program(db, 999999)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Delete Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestDeleteProgram:
|
||||
"""Tests for delete_program."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_delete_program(self, db, ps_program):
|
||||
"""Delete a program removes it from DB."""
|
||||
program_id = ps_program.id
|
||||
self.service.delete_program(db, program_id)
|
||||
|
||||
result = self.service.get_program(db, program_id)
|
||||
assert result is None
|
||||
|
||||
def test_delete_program_also_deletes_settings(self, db, ps_merchant):
|
||||
"""Deleting a program also removes merchant settings."""
|
||||
data = ProgramCreate(loyalty_type="points")
|
||||
program = self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
# Verify settings exist
|
||||
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||
assert settings is not None
|
||||
|
||||
self.service.delete_program(db, program.id)
|
||||
|
||||
# Settings should be gone too
|
||||
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||
assert settings is None
|
||||
|
||||
def test_delete_nonexistent_raises(self, db):
|
||||
"""Deleting non-existent program raises exception."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.delete_program(db, 999999)
|
||||
|
||||
Reference in New Issue
Block a user