"""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