"""Unit tests for PinService.""" import uuid from datetime import UTC, datetime import pytest from app.modules.loyalty.exceptions import ( InvalidStaffPinException, StaffPinLockedException, ) from app.modules.loyalty.models import LoyaltyProgram, StaffPin from app.modules.loyalty.models.loyalty_program import LoyaltyType from app.modules.loyalty.schemas.pin import PinCreate from app.modules.loyalty.services.pin_service import PinService from app.modules.tenancy.models import Merchant, Store, User from app.modules.tenancy.models.store import StoreUser @pytest.mark.unit @pytest.mark.loyalty class TestPinService: """Test suite for PinService.""" def setup_method(self): self.service = PinService() def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def pin_setup(db): """Create a full setup for PIN tests.""" from middleware.auth import AuthManager auth = AuthManager() uid = uuid.uuid4().hex[:8] owner = User( email=f"pinowner_{uid}@test.com", username=f"pinowner_{uid}", hashed_password=auth.hash_password("testpass"), role="merchant_owner", is_active=True, is_email_verified=True, ) db.add(owner) db.commit() db.refresh(owner) merchant = Merchant( name=f"PIN 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) store = Store( merchant_id=merchant.id, store_code=f"PIN_{uid.upper()}", subdomain=f"pin{uid}", name=f"PIN Store {uid}", is_active=True, is_verified=True, ) db.add(store) db.commit() db.refresh(store) store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True) db.add(store_user) db.commit() program = LoyaltyProgram( merchant_id=merchant.id, loyalty_type=LoyaltyType.POINTS.value, points_per_euro=10, cooldown_minutes=0, max_daily_stamps=10, require_staff_pin=True, card_name="PIN Card", card_color="#00FF00", is_active=True, ) db.add(program) db.commit() db.refresh(program) return { "merchant": merchant, "store": store, "program": program, } # ============================================================================ # Create / Unlock Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestCreatePin: """Tests for create_pin.""" def setup_method(self): self.service = PinService() def test_create_pin(self, db, pin_setup): """Create a staff PIN.""" program = pin_setup["program"] store = pin_setup["store"] data = PinCreate(name="Alice", staff_id="EMP001", pin="1234") pin = self.service.create_pin(db, program.id, store.id, data) assert pin.id is not None assert pin.name == "Alice" assert pin.staff_id == "EMP001" assert pin.verify_pin("1234") def test_unlock_pin(self, db, pin_setup): """Unlock a locked PIN.""" program = pin_setup["program"] store = pin_setup["store"] data = PinCreate(name="Bob", staff_id="EMP002", pin="5678") pin = self.service.create_pin(db, program.id, store.id, data) # Lock it pin.failed_attempts = 5 from datetime import timedelta pin.locked_until = datetime.now(UTC) + timedelta(minutes=30) db.commit() assert pin.is_locked # Unlock unlocked = self.service.unlock_pin(db, pin.id) assert unlocked.failed_attempts == 0 assert unlocked.locked_until is None assert not unlocked.is_locked # ============================================================================ # Verify PIN Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestVerifyPin: """Tests for verify_pin.""" def setup_method(self): self.service = PinService() def test_verify_pin_success(self, db, pin_setup): """Correct PIN verifies successfully.""" program = pin_setup["program"] store = pin_setup["store"] data = PinCreate(name="Charlie", staff_id="EMP003", pin="1111") self.service.create_pin(db, program.id, store.id, data) result = self.service.verify_pin(db, program.id, "1111", store_id=store.id) assert result.name == "Charlie" def test_verify_pin_wrong_single_failure(self, db, pin_setup): """Wrong PIN records failure on ONE pin only, not all.""" program = pin_setup["program"] store = pin_setup["store"] # Create two PINs self.service.create_pin(db, program.id, store.id, PinCreate(name="A", pin="1111")) self.service.create_pin(db, program.id, store.id, PinCreate(name="B", pin="2222")) # Wrong PIN with pytest.raises(InvalidStaffPinException): self.service.verify_pin(db, program.id, "9999", store_id=store.id) # Only one PIN should have failed_attempts incremented pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True) failed_counts = [p.failed_attempts for p in pins] assert sum(failed_counts) == 1 # Only 1 PIN got the failure, not both def test_verify_pin_lockout(self, db, pin_setup): """After max failures, PIN gets locked.""" program = pin_setup["program"] store = pin_setup["store"] self.service.create_pin(db, program.id, store.id, PinCreate(name="Lock", pin="3333")) # Fail 5 times (default max) for _ in range(5): try: self.service.verify_pin(db, program.id, "9999", store_id=store.id) except (InvalidStaffPinException, StaffPinLockedException): pass # Next attempt should be locked with pytest.raises((InvalidStaffPinException, StaffPinLockedException)): self.service.verify_pin(db, program.id, "9999", store_id=store.id) def test_verify_skips_locked_pins(self, db, pin_setup): """Locked PINs are skipped during verification.""" program = pin_setup["program"] store = pin_setup["store"] from datetime import timedelta # Create a locked PIN and an unlocked one data1 = PinCreate(name="Locked", pin="1111") pin1 = self.service.create_pin(db, program.id, store.id, data1) pin1.locked_until = datetime.now(UTC) + timedelta(minutes=30) pin1.failed_attempts = 5 db.commit() data2 = PinCreate(name="Active", pin="2222") self.service.create_pin(db, program.id, store.id, data2) # Should find the active PIN result = self.service.verify_pin(db, program.id, "2222", store_id=store.id) assert result.name == "Active" # ============================================================================ # Item 11: Merchant-specific PIN lockout settings # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestVerifyPinMerchantSettings: """Tests for verify_pin using merchant-specific lockout settings.""" def setup_method(self): self.service = PinService() def test_uses_merchant_lockout_attempts(self, db, pin_setup): """Failed attempts use merchant settings, not global config.""" from app.modules.loyalty.models import MerchantLoyaltySettings program = pin_setup["program"] store = pin_setup["store"] merchant = pin_setup["merchant"] # Create merchant settings with low lockout threshold settings = MerchantLoyaltySettings( merchant_id=merchant.id, staff_pin_lockout_attempts=3, staff_pin_lockout_minutes=60, ) db.add(settings) db.commit() self.service.create_pin(db, program.id, store.id, PinCreate(name="Test", pin="1234")) # Fail 3 times (merchant setting), should lock for _ in range(3): try: self.service.verify_pin(db, program.id, "9999", store_id=store.id) except (InvalidStaffPinException, StaffPinLockedException): pass # Next attempt should be locked with pytest.raises((InvalidStaffPinException, StaffPinLockedException)): self.service.verify_pin(db, program.id, "9999", store_id=store.id) # Verify the PIN is actually locked pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True) locked_pins = [p for p in pins if p.is_locked] assert len(locked_pins) >= 1