Files
orion/app/modules/loyalty/tests/unit/test_program_service.py
Samir Boulahtit 540205402f
Some checks failed
CI / pytest (push) Waiting to run
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:13:01 +01:00

776 lines
26 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)
# ============================================================================
# Stats
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestGetProgramStats:
"""Tests for get_program_stats."""
def setup_method(self):
self.service = ProgramService()
def test_stats_returns_all_fields(self, db, ps_program):
"""Stats response includes all required fields."""
stats = self.service.get_program_stats(db, ps_program.id)
assert "total_cards" in stats
assert "active_cards" in stats
assert "new_this_month" in stats
assert "total_points_balance" in stats
assert "avg_points_per_member" in stats
assert "transactions_30d" in stats
assert "points_issued_30d" in stats
assert "points_redeemed_30d" in stats
assert "points_this_month" in stats
assert "points_redeemed_this_month" in stats
assert "estimated_liability_cents" in stats
def test_stats_empty_program(self, db, ps_program):
"""Stats for program with no cards."""
stats = self.service.get_program_stats(db, ps_program.id)
assert stats["total_cards"] == 0
assert stats["active_cards"] == 0
assert stats["new_this_month"] == 0
assert stats["total_points_balance"] == 0
assert stats["avg_points_per_member"] == 0
def test_stats_with_cards(self, db, ps_program, ps_merchant):
"""Stats reflect actual card data."""
from datetime import UTC, datetime
from app.modules.customers.models.customer import Customer
from app.modules.loyalty.models import LoyaltyCard
from app.modules.tenancy.models import Store
uid_store = uuid.uuid4().hex[:8]
store = Store(
merchant_id=ps_merchant.id,
store_code=f"STAT_{uid_store.upper()}",
subdomain=f"stat{uid_store}",
name=f"Stats Store {uid_store}",
is_active=True,
is_verified=True,
)
db.add(store)
db.flush()
# Create cards with customers
customers = []
for i in range(3):
uid = uuid.uuid4().hex[:8]
customers.append(Customer(
email=f"stat_{uid}@test.com",
first_name="Stat",
last_name=f"Customer{i}",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"SC-{uid.upper()}",
store_id=store.id,
is_active=True,
))
db.add_all(customers)
db.flush()
cards = []
for i, customer in enumerate(customers):
cards.append(LoyaltyCard(
merchant_id=ps_merchant.id,
program_id=ps_program.id,
customer_id=customer.id,
card_number=f"STAT-{i}-{uuid.uuid4().hex[:6]}",
points_balance=100 * (i + 1),
total_points_earned=100 * (i + 1),
is_active=True,
last_activity_at=datetime.now(UTC),
))
db.add_all(cards)
db.commit()
stats = self.service.get_program_stats(db, ps_program.id)
assert stats["total_cards"] == 3
assert stats["active_cards"] == 3
assert stats["total_points_balance"] == 600 # 100+200+300
assert stats["avg_points_per_member"] == 200.0 # 600/3
# ============================================================================
# Merchant Stats
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestGetMerchantStats:
"""Tests for get_merchant_stats."""
def setup_method(self):
self.service = ProgramService()
def test_merchant_stats_returns_all_fields(self, db, ps_program, ps_merchant):
"""Merchant stats include all required fields."""
stats = self.service.get_merchant_stats(db, ps_merchant.id)
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 stats, f"Missing field: {field}"
def test_merchant_stats_empty_program(self, db, ps_program, ps_merchant):
"""Merchant stats for program with no cards."""
stats = self.service.get_merchant_stats(db, ps_merchant.id)
assert stats["merchant_id"] == ps_merchant.id
assert stats["program_id"] == ps_program.id
assert stats["total_cards"] == 0
assert stats["active_cards"] == 0
assert stats["new_this_month"] == 0
assert stats["estimated_liability_cents"] == 0
def test_merchant_stats_no_program(self, db):
"""Returns empty stats when merchant has no program."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"noprg_{uid}@test.com",
username=f"noprg_{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"No Program 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)
stats = self.service.get_merchant_stats(db, merchant.id)
assert stats["program_id"] is None
assert stats["total_cards"] == 0
assert stats["program"] is None
assert stats["locations"] == []
def test_merchant_stats_includes_program_info(self, db, ps_program, ps_merchant):
"""Merchant stats include program configuration."""
stats = self.service.get_merchant_stats(db, ps_merchant.id)
assert stats["program"] is not None
assert stats["program"]["id"] == ps_program.id
assert stats["program"]["loyalty_type"] == "points"
assert stats["program"]["points_per_euro"] == 10
def test_merchant_stats_with_cards(self, db, ps_program, ps_merchant):
"""Merchant stats reflect card data including new fields."""
from datetime import UTC, datetime
from app.modules.customers.models.customer import Customer
from app.modules.loyalty.models import LoyaltyCard
from app.modules.tenancy.models import Store
uid_store = uuid.uuid4().hex[:8]
store = Store(
merchant_id=ps_merchant.id,
store_code=f"MSTAT_{uid_store.upper()}",
subdomain=f"mstat{uid_store}",
name=f"Merchant Stats Store {uid_store}",
is_active=True,
is_verified=True,
)
db.add(store)
db.flush()
customers = []
for i in range(2):
uid = uuid.uuid4().hex[:8]
customers.append(Customer(
email=f"mstat_{uid}@test.com",
first_name="MS",
last_name=f"Customer{i}",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"MS-{uid.upper()}",
store_id=store.id,
is_active=True,
))
db.add_all(customers)
db.flush()
cards = []
for i, customer in enumerate(customers):
cards.append(LoyaltyCard(
merchant_id=ps_merchant.id,
program_id=ps_program.id,
customer_id=customer.id,
enrolled_at_store_id=store.id,
card_number=f"MSTAT-{i}-{uuid.uuid4().hex[:6]}",
points_balance=200,
total_points_earned=200,
is_active=True,
last_activity_at=datetime.now(UTC),
))
db.add_all(cards)
db.commit()
stats = self.service.get_merchant_stats(db, ps_merchant.id)
assert stats["total_cards"] == 2
assert stats["active_cards"] == 2
# new_this_month should include cards created today
assert stats["new_this_month"] == 2
# estimated_liability_cents: 2 cards * 200 points each = 400 points
# 400 // 100 * 100 = 400 cents
assert stats["estimated_liability_cents"] == 400
def test_merchant_stats_location_breakdown(self, db, ps_program, ps_merchant):
"""Location breakdown includes per-store data."""
from datetime import UTC, datetime
from app.modules.customers.models.customer import Customer
from app.modules.loyalty.models import LoyaltyCard
from app.modules.tenancy.models import Store
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=ps_merchant.id,
store_code=f"MLOC_{uid.upper()}",
subdomain=f"mloc{uid}",
name=f"Location Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.flush()
customer = Customer(
email=f"mloc_{uid}@test.com",
first_name="Loc",
last_name="Customer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"LC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.flush()
card = LoyaltyCard(
merchant_id=ps_merchant.id,
program_id=ps_program.id,
customer_id=customer.id,
enrolled_at_store_id=store.id,
card_number=f"MLOC-{uuid.uuid4().hex[:6]}",
points_balance=50,
total_points_earned=50,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
stats = self.service.get_merchant_stats(db, ps_merchant.id)
assert len(stats["locations"]) >= 1
# Find our store in locations
our_loc = next(
(loc for loc in stats["locations"] if loc["store_id"] == store.id),
None,
)
assert our_loc is not None
assert our_loc["store_name"] == store.name
assert our_loc["enrolled_count"] == 1
# ============================================================================
# Platform Stats
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestGetPlatformStats:
"""Tests for get_platform_stats."""
def setup_method(self):
self.service = ProgramService()
def test_platform_stats_returns_all_fields(self, db):
"""Platform stats include all required fields."""
stats = self.service.get_platform_stats(db)
expected_fields = [
"total_programs",
"active_programs",
"merchants_with_programs",
"total_cards",
"active_cards",
"transactions_30d",
"points_issued_30d",
"points_redeemed_30d",
"total_points_issued",
"total_points_redeemed",
"total_points_balance",
"new_this_month",
"estimated_liability_cents",
]
for field in expected_fields:
assert field in stats, f"Missing field: {field}"
def test_platform_stats_counts_programs(self, db, ps_program):
"""Platform stats count programs correctly."""
stats = self.service.get_platform_stats(db)
assert stats["total_programs"] >= 1
assert stats["active_programs"] >= 1
assert stats["merchants_with_programs"] >= 1
def test_platform_stats_with_cards(self, db, ps_program, ps_merchant):
"""Platform stats reflect card data across all programs."""
from datetime import UTC, datetime
from app.modules.customers.models.customer import Customer
from app.modules.loyalty.models import LoyaltyCard
from app.modules.tenancy.models import Store
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=ps_merchant.id,
store_code=f"PLAT_{uid.upper()}",
subdomain=f"plat{uid}",
name=f"Platform Stats Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.flush()
customer = Customer(
email=f"plat_{uid}@test.com",
first_name="Plat",
last_name="Customer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"PC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.flush()
card = LoyaltyCard(
merchant_id=ps_merchant.id,
program_id=ps_program.id,
customer_id=customer.id,
card_number=f"PLAT-{uuid.uuid4().hex[:6]}",
points_balance=300,
total_points_earned=300,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
stats = self.service.get_platform_stats(db)
assert stats["total_cards"] >= 1
assert stats["active_cards"] >= 1
assert stats["total_points_balance"] >= 300
assert stats["new_this_month"] >= 1
assert stats["estimated_liability_cents"] >= 0
def test_platform_stats_zero_when_empty(self, db):
"""All numeric fields are zero or positive."""
stats = self.service.get_platform_stats(db)
assert stats["total_cards"] >= 0
assert stats["active_cards"] >= 0
assert stats["transactions_30d"] >= 0
assert stats["points_issued_30d"] >= 0
assert stats["points_redeemed_30d"] >= 0
assert stats["total_points_issued"] >= 0
assert stats["total_points_redeemed"] >= 0
assert stats["total_points_balance"] >= 0
assert stats["new_this_month"] >= 0
assert stats["estimated_liability_cents"] >= 0