- 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>
776 lines
26 KiB
Python
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
|