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