"""Unit tests for StampService.""" import uuid from datetime import UTC, datetime, timedelta import pytest from app.modules.loyalty.exceptions import ( DailyStampLimitException, InsufficientStampsException, StampCooldownException, ) 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.stamp_service import StampService from app.modules.tenancy.models import Merchant, Store, User from app.modules.tenancy.models.store import StoreUser @pytest.mark.unit @pytest.mark.loyalty class TestStampService: """Test suite for StampService.""" def setup_method(self): self.service = StampService() def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def stamp_setup(db): """Create a full setup for stamp tests with stamps-type program.""" from app.modules.customers.models.customer import Customer from middleware.auth import AuthManager auth = AuthManager() uid = uuid.uuid4().hex[:8] owner = User( email=f"stampowner_{uid}@test.com", username=f"stampowner_{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"Stamp 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"STAMP_{uid.upper()}", subdomain=f"stamp{uid}", name=f"Stamp 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() customer = Customer( email=f"stampcust_{uid}@test.com", first_name="Stamp", last_name="Customer", hashed_password="!unused!", # noqa: SEC001 customer_number=f"SC-{uid.upper()}", store_id=store.id, is_active=True, ) db.add(customer) db.commit() db.refresh(customer) program = LoyaltyProgram( merchant_id=merchant.id, loyalty_type=LoyaltyType.STAMPS.value, stamps_target=5, stamps_reward_description="Free coffee", stamps_reward_value_cents=500, cooldown_minutes=0, max_daily_stamps=10, require_staff_pin=False, card_name="Stamp Card", card_color="#FF0000", is_active=True, points_per_euro=1, ) db.add(program) db.commit() db.refresh(program) card = LoyaltyCard( merchant_id=merchant.id, program_id=program.id, customer_id=customer.id, enrolled_at_store_id=store.id, card_number=f"STAMPCARD-{uid.upper()}", stamp_count=0, total_stamps_earned=0, stamps_redeemed=0, points_balance=0, total_points_earned=0, points_redeemed=0, is_active=True, last_activity_at=datetime.now(UTC), ) db.add(card) db.commit() db.refresh(card) return { "merchant": merchant, "store": store, "customer": customer, "program": program, "card": card, } # ============================================================================ # Add Stamp Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestAddStamp: """Tests for add_stamp.""" def setup_method(self): self.service = StampService() def test_add_stamp_success(self, db, stamp_setup): """Successfully add a stamp to a card.""" card = stamp_setup["card"] store = stamp_setup["store"] result = self.service.add_stamp(db, store_id=store.id, card_id=card.id) assert result["success"] is True assert result["stamp_count"] == 1 assert result["stamps_target"] == 5 assert result["stamps_until_reward"] == 4 def test_add_stamp_cooldown_violation(self, db, stamp_setup): """Stamp within cooldown period raises exception.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] # Set cooldown program.cooldown_minutes = 15 db.commit() # Add first stamp self.service.add_stamp(db, store_id=store.id, card_id=card.id) # Second stamp should fail (cooldown) with pytest.raises(StampCooldownException): self.service.add_stamp(db, store_id=store.id, card_id=card.id) def test_add_stamp_daily_limit(self, db, stamp_setup): """Exceeding daily stamp limit raises exception.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] # Set max 2 daily stamps program.max_daily_stamps = 2 db.commit() # Add 2 stamps self.service.add_stamp(db, store_id=store.id, card_id=card.id) self.service.add_stamp(db, store_id=store.id, card_id=card.id) # Third should fail with pytest.raises(DailyStampLimitException): self.service.add_stamp(db, store_id=store.id, card_id=card.id) # ============================================================================ # Redeem Stamps Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestRedeemStamps: """Tests for redeem_stamps.""" def setup_method(self): self.service = StampService() def test_redeem_stamps_success(self, db, stamp_setup): """Successfully redeem stamps for a reward.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] # Give enough stamps card.stamp_count = program.stamps_target card.total_stamps_earned = program.stamps_target db.commit() result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id) assert result["success"] is True assert result["stamp_count"] == 0 assert result["reward_description"] == "Free coffee" def test_redeem_stamps_insufficient(self, db, stamp_setup): """Redeeming without enough stamps raises exception.""" card = stamp_setup["card"] store = stamp_setup["store"] # Card has 0 stamps, needs 5 with pytest.raises(InsufficientStampsException): self.service.redeem_stamps(db, store_id=store.id, card_id=card.id) # ============================================================================ # Void Stamps Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestVoidStamps: """Tests for void_stamps.""" def setup_method(self): self.service = StampService() def test_void_stamps_by_transaction(self, db, stamp_setup): """Void stamps by original transaction ID.""" card = stamp_setup["card"] store = stamp_setup["store"] # Add a stamp first result = self.service.add_stamp(db, store_id=store.id, card_id=card.id) assert result["stamp_count"] == 1 # Find the transaction tx = ( db.query(LoyaltyTransaction) .filter( LoyaltyTransaction.card_id == card.id, LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value, ) .first() ) void_result = self.service.void_stamps( db, store_id=store.id, card_id=card.id, original_transaction_id=tx.id, ) assert void_result["success"] is True assert void_result["stamp_count"] == 0 # ============================================================================ # Item 1: TOCTOU Race Condition (stamp checks after lock) # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestStampTOCTOU: """Verify cooldown/daily-limit checks happen after row lock.""" def setup_method(self): self.service = StampService() def test_cooldown_checked_after_lock(self, db, stamp_setup): """Cooldown check uses locked row state.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] # Enable cooldown program.cooldown_minutes = 60 db.commit() # First stamp succeeds self.service.add_stamp(db, store_id=store.id, card_id=card.id) # Second stamp should fail (cooldown active after lock) with pytest.raises(StampCooldownException): self.service.add_stamp(db, store_id=store.id, card_id=card.id) def test_daily_limit_checked_after_lock(self, db, stamp_setup): """Daily limit check uses locked row state.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] program.max_daily_stamps = 1 db.commit() # First stamp succeeds self.service.add_stamp(db, store_id=store.id, card_id=card.id) # Second stamp should fail (daily limit after lock) with pytest.raises(DailyStampLimitException): self.service.add_stamp(db, store_id=store.id, card_id=card.id) def test_redeem_stamps_insufficient_after_lock(self, db, stamp_setup): """Stamp count check uses locked row state.""" card = stamp_setup["card"] store = stamp_setup["store"] program = stamp_setup["program"] # Give exact stamp target card.stamp_count = program.stamps_target card.total_stamps_earned = program.stamps_target db.commit() # First redeem succeeds result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id) assert result["success"] is True # Second redeem should fail (0 stamps after lock) with pytest.raises(InsufficientStampsException): self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)