# tests/unit/services/test_loyalty_services.py """ Unit tests for Loyalty module services. Tests cover: - Program service: CRUD operations, company-based queries - Card service: Enrollment, lookup, balance operations - Points service: Earn, redeem, void operations - PIN service: Verification, lockout """ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import pytest from app.modules.loyalty.exceptions import ( LoyaltyCardNotFoundException, LoyaltyException, LoyaltyProgramNotFoundException, ) from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction from app.modules.loyalty.models.loyalty_program import LoyaltyType from app.modules.loyalty.models.loyalty_transaction import TransactionType from app.modules.loyalty.services import ( card_service, pin_service, points_service, program_service, ) # ============================================================================= # Program Service Tests # ============================================================================= @pytest.mark.unit @pytest.mark.service class TestProgramService: """Tests for program_service.""" def test_get_program_by_company(self, db, test_loyalty_program): """Test getting a program by company ID.""" program = program_service.get_program_by_company( db, test_loyalty_program.company_id ) assert program is not None assert program.id == test_loyalty_program.id assert program.company_id == test_loyalty_program.company_id def test_get_program_by_company_not_found(self, db): """Test getting a program for non-existent company.""" program = program_service.get_program_by_company(db, 99999) assert program is None def test_get_program_by_vendor(self, db, test_loyalty_program, test_vendor): """Test getting a program by vendor ID.""" program = program_service.get_program_by_vendor(db, test_vendor.id) assert program is not None assert program.company_id == test_vendor.company_id def test_list_programs(self, db, test_loyalty_program): """Test listing all programs with pagination.""" programs, total = program_service.list_programs(db, skip=0, limit=10) assert total >= 1 assert any(p.id == test_loyalty_program.id for p in programs) def test_list_programs_active_only(self, db, test_loyalty_program): """Test listing only active programs.""" programs, total = program_service.list_programs( db, skip=0, limit=10, active_only=True ) assert all(p.is_active for p in programs) # ============================================================================= # Card Service Tests # ============================================================================= @pytest.mark.unit @pytest.mark.service class TestCardService: """Tests for card_service.""" def test_get_card_by_id(self, db, test_loyalty_card): """Test getting a card by ID.""" card = card_service.get_card(db, test_loyalty_card.id) assert card is not None assert card.id == test_loyalty_card.id def test_get_card_not_found(self, db): """Test getting a non-existent card.""" with pytest.raises(LoyaltyCardNotFoundException): card_service.get_card(db, 99999) def test_get_card_by_number(self, db, test_loyalty_card): """Test getting a card by card number.""" card = card_service.get_card_by_number( db, test_loyalty_card.company_id, test_loyalty_card.card_number ) assert card is not None assert card.card_number == test_loyalty_card.card_number def test_get_card_by_customer_email(self, db, test_loyalty_card): """Test getting a card by customer email.""" card = card_service.get_card_by_customer_email( db, test_loyalty_card.company_id, test_loyalty_card.customer_email ) assert card is not None assert card.customer_email == test_loyalty_card.customer_email def test_lookup_card(self, db, test_loyalty_card): """Test looking up a card by various identifiers.""" # By card number card = card_service.lookup_card( db, test_loyalty_card.company_id, test_loyalty_card.card_number ) assert card is not None assert card.id == test_loyalty_card.id # By email card = card_service.lookup_card( db, test_loyalty_card.company_id, test_loyalty_card.customer_email ) assert card is not None assert card.id == test_loyalty_card.id def test_enroll_customer(self, db, test_loyalty_program, test_vendor): """Test enrolling a new customer.""" card = card_service.enroll_customer( db, vendor_id=test_vendor.id, customer_email="newmember@test.com", customer_name="New Member", customer_phone="+352123456789", ) db.commit() assert card is not None assert card.customer_email == "newmember@test.com" assert card.company_id == test_vendor.company_id # Check welcome bonus was applied assert card.points_balance == test_loyalty_program.welcome_bonus_points def test_enroll_customer_duplicate(self, db, test_loyalty_card, test_vendor): """Test enrolling an existing customer raises error.""" with pytest.raises(LoyaltyException) as exc_info: card_service.enroll_customer( db, vendor_id=test_vendor.id, customer_email=test_loyalty_card.customer_email, customer_name="Duplicate", ) assert "already enrolled" in str(exc_info.value).lower() # ============================================================================= # Points Service Tests # ============================================================================= @pytest.mark.unit @pytest.mark.service class TestPointsService: """Tests for points_service.""" def test_earn_points(self, db, test_loyalty_card, test_vendor, test_staff_pin): """Test earning points.""" initial_balance = test_loyalty_card.points_balance purchase_amount_cents = 5000 # €50 result = points_service.earn_points( db, card_id=test_loyalty_card.id, vendor_id=test_vendor.id, purchase_amount_cents=purchase_amount_cents, staff_pin_id=test_staff_pin.id, ) db.commit() assert result is not None assert result["points_earned"] > 0 assert result["new_balance"] > initial_balance def test_redeem_points(self, db, test_loyalty_card, test_vendor, test_staff_pin): """Test redeeming points.""" # Ensure card has enough points test_loyalty_card.points_balance = 200 db.commit() initial_balance = test_loyalty_card.points_balance result = points_service.redeem_points( db, card_id=test_loyalty_card.id, vendor_id=test_vendor.id, reward_id="reward_1", # 100 points staff_pin_id=test_staff_pin.id, ) db.commit() assert result is not None assert result["points_redeemed"] == 100 assert result["new_balance"] == initial_balance - 100 def test_redeem_points_insufficient_balance( self, db, test_loyalty_card, test_vendor, test_staff_pin ): """Test redeeming points with insufficient balance.""" test_loyalty_card.points_balance = 50 # Less than minimum db.commit() with pytest.raises(LoyaltyException) as exc_info: points_service.redeem_points( db, card_id=test_loyalty_card.id, vendor_id=test_vendor.id, reward_id="reward_1", # 100 points needed staff_pin_id=test_staff_pin.id, ) assert "insufficient" in str(exc_info.value).lower() def test_void_points(self, db, test_loyalty_card, test_vendor, test_staff_pin): """Test voiding points (for returns).""" initial_balance = test_loyalty_card.points_balance points_to_void = 50 result = points_service.void_points( db, card_id=test_loyalty_card.id, vendor_id=test_vendor.id, points_to_void=points_to_void, reason="Customer return", staff_pin_id=test_staff_pin.id, ) db.commit() assert result is not None assert result["points_voided"] == points_to_void assert result["new_balance"] == initial_balance - points_to_void def test_adjust_points(self, db, test_loyalty_card, test_vendor): """Test manual points adjustment.""" initial_balance = test_loyalty_card.points_balance adjustment = 25 result = points_service.adjust_points( db, card_id=test_loyalty_card.id, vendor_id=test_vendor.id, points_delta=adjustment, reason="Manual correction", ) db.commit() assert result is not None assert result["new_balance"] == initial_balance + adjustment # ============================================================================= # PIN Service Tests # ============================================================================= @pytest.mark.unit @pytest.mark.service class TestPinService: """Tests for pin_service.""" def test_verify_pin_success(self, db, test_staff_pin): """Test successful PIN verification.""" result = pin_service.verify_pin( db, pin_id=test_staff_pin.id, pin="1234", ) assert result is True def test_verify_pin_wrong_pin(self, db, test_staff_pin): """Test PIN verification with wrong PIN.""" result = pin_service.verify_pin( db, pin_id=test_staff_pin.id, pin="9999", ) assert result is False def test_verify_pin_increments_failed_attempts(self, db, test_staff_pin): """Test that failed verification increments attempt counter.""" initial_attempts = test_staff_pin.failed_attempts or 0 pin_service.verify_pin(db, pin_id=test_staff_pin.id, pin="wrong") db.refresh(test_staff_pin) assert test_staff_pin.failed_attempts == initial_attempts + 1 def test_create_pin(self, db, test_loyalty_program, test_vendor): """Test creating a new staff PIN.""" pin = pin_service.create_pin( db, program_id=test_loyalty_program.id, vendor_id=test_vendor.id, staff_name="New Staff Member", pin="5678", ) db.commit() assert pin is not None assert pin.staff_name == "New Staff Member" assert pin.is_active is True # Verify PIN works assert pin_service.verify_pin(db, pin.id, "5678") is True def test_list_pins_for_vendor(self, db, test_staff_pin, test_vendor): """Test listing PINs for a vendor.""" pins = pin_service.list_pins_for_vendor(db, test_vendor.id) assert len(pins) >= 1 assert any(p.id == test_staff_pin.id for p in pins)