"""Unit tests for PointsService.""" import uuid from datetime import UTC, datetime import pytest from app.modules.loyalty.exceptions import ( InsufficientPointsException, InvalidRewardException, ) 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.points_service import PointsService from app.modules.tenancy.models import Merchant, Store, User from app.modules.tenancy.models.store import StoreUser @pytest.mark.unit @pytest.mark.loyalty class TestPointsService: """Test suite for PointsService.""" def setup_method(self): self.service = PointsService() def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def points_setup(db): """Create a full setup for points tests.""" from app.modules.customers.models.customer import Customer from middleware.auth import AuthManager auth = AuthManager() uid = uuid.uuid4().hex[:8] owner = User( email=f"ptsowner_{uid}@test.com", username=f"ptsowner_{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"Points 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"PTS_{uid.upper()}", subdomain=f"pts{uid}", name=f"Points 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"ptscust_{uid}@test.com", first_name="Points", 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.commit() db.refresh(customer) program = LoyaltyProgram( merchant_id=merchant.id, loyalty_type=LoyaltyType.POINTS.value, points_per_euro=10, welcome_bonus_points=0, minimum_redemption_points=50, minimum_purchase_cents=100, cooldown_minutes=0, max_daily_stamps=10, require_staff_pin=False, card_name="Points Card", card_color="#0000FF", is_active=True, points_rewards=[ {"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True}, {"id": "r2", "name": "10 EUR off", "points_required": 200, "is_active": True}, {"id": "r3", "name": "Inactive Reward", "points_required": 50, "is_active": False}, ], ) 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"PTSCARD-{uid.upper()}", stamp_count=0, total_stamps_earned=0, stamps_redeemed=0, points_balance=500, total_points_earned=500, 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, } # ============================================================================ # Earn Points Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestEarnPoints: """Tests for earn_points.""" def setup_method(self): self.service = PointsService() def test_earn_points_calculation(self, db, points_setup): """Points calculated correctly from purchase amount.""" card = points_setup["card"] store = points_setup["store"] result = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=2000, # 20 EUR ) assert result["success"] is True assert result["points_earned"] == 200 # 20 EUR * 10 pts/EUR assert result["points_balance"] == 700 # 500 + 200 def test_earn_points_minimum_purchase(self, db, points_setup): """Below minimum purchase returns 0 points.""" card = points_setup["card"] store = points_setup["store"] result = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=50, # Below min of 100 ) assert result["success"] is True assert result["points_earned"] == 0 # ============================================================================ # Redeem Points Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestRedeemPoints: """Tests for redeem_points.""" def setup_method(self): self.service = PointsService() def test_redeem_points_success(self, db, points_setup): """Successfully redeem points for a reward.""" card = points_setup["card"] store = points_setup["store"] result = self.service.redeem_points( db, store_id=store.id, card_id=card.id, reward_id="r1", ) assert result["success"] is True assert result["points_spent"] == 100 assert result["points_balance"] == 400 # 500 - 100 def test_redeem_points_insufficient(self, db, points_setup): """Redeeming without enough points raises exception.""" card = points_setup["card"] store = points_setup["store"] # Set balance low card.points_balance = 50 db.commit() with pytest.raises(InsufficientPointsException): self.service.redeem_points( db, store_id=store.id, card_id=card.id, reward_id="r1", # needs 100 ) def test_redeem_inactive_reward(self, db, points_setup): """Redeeming an inactive reward raises exception.""" card = points_setup["card"] store = points_setup["store"] with pytest.raises(InvalidRewardException): self.service.redeem_points( db, store_id=store.id, card_id=card.id, reward_id="r3", # inactive ) # ============================================================================ # Void Points Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestVoidPoints: """Tests for void_points.""" def setup_method(self): self.service = PointsService() def test_void_by_transaction(self, db, points_setup): """Void points by original transaction ID.""" card = points_setup["card"] store = points_setup["store"] # Earn some points earn_result = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, ) assert earn_result["points_earned"] == 100 # Find the earn transaction tx = ( db.query(LoyaltyTransaction) .filter( LoyaltyTransaction.card_id == card.id, LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value, ) .first() ) void_result = self.service.void_points( db, store_id=store.id, card_id=card.id, original_transaction_id=tx.id, ) assert void_result["success"] is True assert void_result["points_voided"] == 100 def test_void_by_order_reference(self, db, points_setup): """Void points by order reference.""" card = points_setup["card"] store = points_setup["store"] # Earn with order reference self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=2000, order_reference="ORDER-VOID-TEST", ) void_result = self.service.void_points( db, store_id=store.id, card_id=card.id, order_reference="ORDER-VOID-TEST", ) assert void_result["success"] is True assert void_result["points_voided"] == 200 # ============================================================================ # Adjust Points Tests # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestAdjustPoints: """Tests for adjust_points.""" def setup_method(self): self.service = PointsService() def test_adjust_positive(self, db, points_setup): """Add points via adjustment.""" card = points_setup["card"] result = self.service.adjust_points( db, card_id=card.id, points_delta=50, reason="Goodwill bonus", ) assert result["success"] is True assert result["points_balance"] == 550 # 500 + 50 def test_adjust_negative(self, db, points_setup): """Remove points via adjustment.""" card = points_setup["card"] result = self.service.adjust_points( db, card_id=card.id, points_delta=-100, reason="Correction", ) assert result["success"] is True assert result["points_balance"] == 400 # 500 - 100 def test_adjust_floor_at_zero(self, db, points_setup): """Negative adjustment doesn't go below zero.""" card = points_setup["card"] result = self.service.adjust_points( db, card_id=card.id, points_delta=-9999, reason="Full correction", ) assert result["success"] is True assert result["points_balance"] == 0 # ============================================================================ # Item 1: TOCTOU Race Condition (redeem_points checks after lock) # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestRedeemPointsTOCTOU: """Verify balance check happens after row lock.""" def setup_method(self): self.service = PointsService() def test_redeem_insufficient_after_lock(self, db, points_setup): """Balance check uses locked row state, not stale read.""" card = points_setup["card"] store = points_setup["store"] # Set balance to exactly the reward cost card.points_balance = 100 db.commit() # Should succeed — balance is exactly enough result = self.service.redeem_points( db, store_id=store.id, card_id=card.id, reward_id="r1", ) assert result["success"] is True assert result["points_balance"] == 0 # Second redemption should fail (balance is now 0 after lock) with pytest.raises(InsufficientPointsException): self.service.redeem_points( db, store_id=store.id, card_id=card.id, reward_id="r1", ) # ============================================================================ # Item 4: void_points updates total_points_voided # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestVoidPointsTracking: """Verify void_points increments total_points_voided.""" def setup_method(self): self.service = PointsService() def test_void_updates_total_points_voided(self, db, points_setup): """total_points_voided is incremented on void.""" card = points_setup["card"] store = points_setup["store"] initial_voided = card.total_points_voided self.service.void_points( db, store_id=store.id, card_id=card.id, points_to_void=50, ) db.refresh(card) assert card.total_points_voided == initial_voided + 50 def test_void_caps_voided_at_balance(self, db, points_setup): """total_points_voided only counts actually voided amount (capped at balance).""" card = points_setup["card"] store = points_setup["store"] # Set balance to 30, try to void 100 card.points_balance = 30 card.total_points_voided = 0 db.commit() self.service.void_points( db, store_id=store.id, card_id=card.id, points_to_void=100, ) db.refresh(card) assert card.total_points_voided == 30 # Only 30 was available assert card.points_balance == 0 # ============================================================================ # Item 5: Duplicate order_reference guard # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestEarnPointsIdempotency: """Verify duplicate order_reference returns existing result.""" def setup_method(self): self.service = PointsService() def test_duplicate_order_reference_returns_existing(self, db, points_setup): """Same order_reference returns existing result without double-earning.""" card = points_setup["card"] store = points_setup["store"] result1 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, order_reference="ORDER-DUP-001", ) balance_after_first = result1["points_balance"] result2 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, order_reference="ORDER-DUP-001", ) # Second call should return same points_earned, balance unchanged assert result2["points_earned"] == result1["points_earned"] assert result2["points_balance"] == balance_after_first assert result2["message"] == "Points already earned for this order" def test_different_order_references_earn_separately(self, db, points_setup): """Different order_references earn points independently.""" card = points_setup["card"] store = points_setup["store"] result1 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, order_reference="ORDER-A", ) result2 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, order_reference="ORDER-B", ) assert result2["points_balance"] > result1["points_balance"] assert result2["message"] == "Points earned successfully" def test_no_order_reference_allows_multiple_earns(self, db, points_setup): """Without order_reference, multiple earns are allowed.""" card = points_setup["card"] store = points_setup["store"] result1 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, ) result2 = self.service.earn_points( db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000, ) assert result2["points_balance"] > result1["points_balance"]