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>
355 lines
12 KiB
Python
355 lines
12 KiB
Python
"""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
|
|
@pytest.mark.loyalty
|
|
class TestProgramService:
|
|
"""Test suite for ProgramService."""
|
|
|
|
def setup_method(self):
|
|
self.service = ProgramService()
|
|
|
|
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)
|