"""Unit tests for CardService.""" import uuid import pytest from app.modules.customers.models.customer import Customer from app.modules.loyalty.exceptions import ( CustomerIdentifierRequiredException, CustomerNotFoundByEmailException, ) from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction from app.modules.loyalty.models.loyalty_transaction import TransactionType from app.modules.loyalty.services.card_service import CardService @pytest.mark.unit @pytest.mark.loyalty class TestCardService: """Test suite for CardService.""" def setup_method(self): self.service = CardService() def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None @pytest.mark.unit @pytest.mark.loyalty class TestGetCardByNumber: """Tests for card number lookup with SQL normalization (func.replace).""" def setup_method(self): self.service = CardService() def test_find_card_by_exact_number(self, db, test_loyalty_card): """Find card by its exact stored card number.""" result = self.service.get_card_by_number(db, test_loyalty_card.card_number) assert result is not None assert result.id == test_loyalty_card.id def test_find_card_with_dashes_in_query(self, db, test_loyalty_program, test_customer, test_store): """Query with dashes matches card stored without dashes.""" card = LoyaltyCard( merchant_id=test_loyalty_program.merchant_id, program_id=test_loyalty_program.id, customer_id=test_customer.id, enrolled_at_store_id=test_store.id, card_number="ABCD1234", is_active=True, ) db.add(card) db.commit() result = self.service.get_card_by_number(db, "ABCD-1234") assert result is not None assert result.id == card.id def test_find_card_with_spaces_in_query(self, db, test_loyalty_program, test_customer, test_store): """Query with spaces matches card stored without spaces.""" card = LoyaltyCard( merchant_id=test_loyalty_program.merchant_id, program_id=test_loyalty_program.id, customer_id=test_customer.id, enrolled_at_store_id=test_store.id, card_number="WXYZ5678", is_active=True, ) db.add(card) db.commit() result = self.service.get_card_by_number(db, "WXYZ 5678") assert result is not None assert result.id == card.id def test_card_not_found_returns_none(self, db): """Non-existent card number returns None.""" result = self.service.get_card_by_number(db, "NONEXISTENT-999") assert result is None @pytest.mark.unit @pytest.mark.loyalty class TestSearchCardForStore: """Tests for free-text card search scoped to store's merchant.""" def setup_method(self): self.service = CardService() def test_search_by_card_number(self, db, test_loyalty_card, test_store): """Find card by card number.""" result = self.service.search_card_for_store( db, test_store.id, test_loyalty_card.card_number ) assert result is not None assert result.id == test_loyalty_card.id def test_search_by_customer_email(self, db, test_loyalty_card, test_store, test_customer): """Find card by customer email.""" result = self.service.search_card_for_store( db, test_store.id, test_customer.email ) assert result is not None assert result.id == test_loyalty_card.id def test_search_returns_none_for_wrong_merchant(self, db, test_loyalty_card, test_store): """Card from a different merchant is not returned.""" from app.modules.tenancy.models import Merchant, Store, User from middleware.auth import AuthManager auth = AuthManager() uid = str(uuid.uuid4())[:8] other_owner = User( email=f"other_{uid}@test.com", username=f"other_{uid}", hashed_password=auth.hash_password("pass123"), role="merchant_owner", is_active=True, ) db.add(other_owner) db.flush() other_merchant = Merchant( name="Other Merchant", owner_user_id=other_owner.id, contact_email="other@test.com", is_active=True, ) db.add(other_merchant) db.flush() other_store = Store( store_code=f"OTHER{uid}", subdomain=f"other{uid}", name="Other Store", merchant_id=other_merchant.id, is_active=True, ) db.add(other_store) db.commit() # Search from the other store — should not find the card result = self.service.search_card_for_store( db, other_store.id, test_loyalty_card.card_number ) assert result is None def test_search_returns_none_for_nonexistent(self, db, test_store): """No match returns None.""" result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com") assert result is None def test_search_email_finds_cross_store_card(self, db, loyalty_store_setup): """Email search at store2 finds a card enrolled at store1 when cross-location is enabled.""" setup = loyalty_store_setup customer = setup["customer"] card = setup["card"] merchant = setup["merchant"] from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"XSRCH_{uuid.uuid4().hex[:6].upper()}", subdomain=f"xsrch{uuid.uuid4().hex[:6]}", name="Cross Search Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() # Cross-location is enabled by default — should find the card result = self.service.search_card_for_store( db, store2.id, customer.email ) assert result is not None assert result.id == card.id def test_search_email_no_cross_store_when_disabled( self, db, loyalty_store_setup ): """Email search at store2 does NOT find cross-store cards when cross-location is disabled.""" setup = loyalty_store_setup customer = setup["customer"] merchant = setup["merchant"] from app.modules.loyalty.services.program_service import program_service settings = program_service.get_or_create_merchant_settings( db, merchant.id ) settings.allow_cross_location_redemption = False db.commit() from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"NOSRCH_{uuid.uuid4().hex[:6].upper()}", subdomain=f"nosrch{uuid.uuid4().hex[:6]}", name="No Cross Search Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() result = self.service.search_card_for_store( db, store2.id, customer.email ) assert result is None @pytest.mark.unit @pytest.mark.loyalty class TestResolveCustomerId: """Tests for customer ID resolution including self-enrollment creation.""" def setup_method(self): self.service = CardService() def test_resolve_by_customer_id(self, db, test_customer, test_store): """Returns immediately when customer_id is provided.""" result = self.service.resolve_customer_id( db, customer_id=test_customer.id, email=None, store_id=test_store.id ) assert result == test_customer.id def test_resolve_by_email(self, db, test_customer, test_store): """Finds existing customer by email.""" result = self.service.resolve_customer_id( db, customer_id=None, email=test_customer.email, store_id=test_store.id ) assert result == test_customer.id def test_resolve_creates_customer_when_missing(self, db, test_store): """create_if_missing=True creates a new customer.""" email = f"newcustomer-{uuid.uuid4().hex[:8]}@test.com" result = self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_name="Jane Doe", customer_phone="+352123456", ) assert result is not None customer = db.query(Customer).filter(Customer.id == result).first() assert customer is not None assert customer.email == email assert customer.first_name == "Jane" assert customer.last_name == "Doe" assert customer.phone == "+352123456" assert customer.hashed_password.startswith("!enrollment!") assert customer.customer_number is not None assert customer.is_active is True def test_resolve_splits_name_into_first_last(self, db, test_store): """Full name with space is split into first_name and last_name.""" email = f"split-{uuid.uuid4().hex[:8]}@test.com" result = self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_name="John Michael Smith", ) customer = db.query(Customer).filter(Customer.id == result).first() assert customer.first_name == "John" assert customer.last_name == "Michael Smith" def test_resolve_raises_when_email_not_found(self, db, test_store): """create_if_missing=False raises when email not found.""" with pytest.raises(CustomerNotFoundByEmailException): self.service.resolve_customer_id( db, customer_id=None, email="nonexistent@test.com", store_id=test_store.id, create_if_missing=False, ) def test_resolve_raises_when_no_identifier(self, db, test_store): """Raises when neither customer_id nor email provided.""" with pytest.raises(CustomerIdentifierRequiredException): self.service.resolve_customer_id( db, customer_id=None, email=None, store_id=test_store.id ) def test_resolve_persists_birthday_on_create(self, db, test_store): """Birthday provided at self-enrollment is saved on the new customer.""" from datetime import date email = f"birthday-create-{uuid.uuid4().hex[:8]}@test.com" result = self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_name="Anna Test", customer_birthday=date(1992, 6, 15), ) customer = db.query(Customer).filter(Customer.id == result).first() assert customer.birth_date == date(1992, 6, 15) def test_resolve_backfills_birthday_on_existing_customer( self, db, test_store ): """An existing customer without a birthday is backfilled from form.""" from datetime import date email = f"backfill-{uuid.uuid4().hex[:8]}@test.com" # Pre-create a customer with no birth_date existing = self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_name="No Birthday", ) customer = db.query(Customer).filter(Customer.id == existing).first() assert customer.birth_date is None # Re-enroll with birthday — should backfill self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_birthday=date(1985, 3, 22), ) db.refresh(customer) assert customer.birth_date == date(1985, 3, 22) def test_resolve_does_not_overwrite_existing_birthday(self, db, test_store): """Birthday already on the customer is not overwritten by form input.""" from datetime import date email = f"no-overwrite-{uuid.uuid4().hex[:8]}@test.com" # Create with original birthday cid = self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_birthday=date(1990, 1, 1), ) # Re-enroll with a different birthday self.service.resolve_customer_id( db, customer_id=None, email=email, store_id=test_store.id, create_if_missing=True, customer_birthday=date(2000, 1, 1), ) customer = db.query(Customer).filter(Customer.id == cid).first() assert customer.birth_date == date(1990, 1, 1) @pytest.mark.unit @pytest.mark.loyalty class TestGetStoreTransactions: """Tests for merchant-scoped transaction retrieval with eager loading.""" def setup_method(self): self.service = CardService() def test_returns_transactions_for_merchant( self, db, test_loyalty_card, test_loyalty_transaction ): """Returns transactions belonging to the merchant.""" transactions, total = self.service.get_store_transactions( db, test_loyalty_card.merchant_id ) assert total >= 1 assert len(transactions) >= 1 assert any(t.id == test_loyalty_transaction.id for t in transactions) def test_eager_loads_customer( self, db, test_loyalty_card, test_loyalty_transaction ): """Verifies card.customer is accessible without lazy-load error.""" transactions, _ = self.service.get_store_transactions( db, test_loyalty_card.merchant_id ) tx = transactions[0] # This should not raise a lazy-loading error assert tx.card is not None assert tx.card.customer is not None assert tx.card.customer.email is not None def test_pagination(self, db, test_loyalty_card, test_store): """Skip and limit work correctly.""" from datetime import UTC, datetime # Create 3 transactions txns = [ LoyaltyTransaction( merchant_id=test_loyalty_card.merchant_id, card_id=test_loyalty_card.id, store_id=test_store.id, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=10 * (i + 1), transaction_at=datetime.now(UTC), ) for i in range(3) ] db.add_all(txns) db.commit() transactions, total = self.service.get_store_transactions( db, test_loyalty_card.merchant_id, skip=0, limit=2 ) assert total >= 3 assert len(transactions) == 2 # ============================================================================ # Item 9: Reactivate card creates audit transaction # ============================================================================ @pytest.mark.unit @pytest.mark.loyalty class TestReactivateCardAudit: """Tests for reactivate_card creating CARD_REACTIVATED transaction.""" def setup_method(self): self.service = CardService() def test_reactivate_creates_transaction(self, db, test_loyalty_card): """Reactivating a card creates a CARD_REACTIVATED transaction.""" # Deactivate first test_loyalty_card.is_active = False db.commit() self.service.reactivate_card(db, test_loyalty_card.id) tx = ( db.query(LoyaltyTransaction) .filter( LoyaltyTransaction.card_id == test_loyalty_card.id, LoyaltyTransaction.transaction_type == TransactionType.CARD_REACTIVATED.value, ) .first() ) assert tx is not None assert tx.merchant_id == test_loyalty_card.merchant_id def test_reactivate_sets_card_active(self, db, test_loyalty_card): """Reactivating a card sets is_active to True.""" test_loyalty_card.is_active = False db.commit() card = self.service.reactivate_card(db, test_loyalty_card.id) assert card.is_active is True @pytest.mark.unit @pytest.mark.loyalty class TestGetCardByCustomerAndStore: """Tests for the per-store card lookup.""" def setup_method(self): self.service = CardService() def test_finds_card_at_store(self, db, loyalty_store_setup): """Returns card when customer has one at the given store.""" setup = loyalty_store_setup result = self.service.get_card_by_customer_and_store( db, setup["customer"].id, setup["store"].id ) assert result is not None assert result.id == setup["card"].id def test_returns_none_at_different_store(self, db, loyalty_store_setup): """Returns None when customer has no card at the given store.""" from app.modules.tenancy.models import Store setup = loyalty_store_setup store2 = Store( merchant_id=setup["merchant"].id, store_code=f"NOCARD_{uuid.uuid4().hex[:6].upper()}", subdomain=f"nocard{uuid.uuid4().hex[:6]}", name="No Card Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() result = self.service.get_card_by_customer_and_store( db, setup["customer"].id, store2.id ) assert result is None @pytest.mark.unit @pytest.mark.loyalty class TestCrossStoreEnrollment: """ Tests for cross-store enrollment with merchant_id-aware resolution. The customer model is store-scoped, but loyalty cards are merchant-scoped. When a customer enrolls at store1 and then at store2 (same merchant), resolve_customer_id should find the existing customer from store1 via the cross-store loyalty card lookup. """ def setup_method(self): self.service = CardService() def test_resolve_finds_existing_cardholder_across_stores( self, db, loyalty_store_setup ): """Same email at a different store returns the original customer_id when merchant_id is provided and they already have a card.""" setup = loyalty_store_setup merchant = setup["merchant"] customer = setup["customer"] # Already has a card at store1 # Create a second store under the same merchant from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"SECOND_{uuid.uuid4().hex[:6].upper()}", subdomain=f"second{uuid.uuid4().hex[:6]}", name="Second Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() db.refresh(store2) # Resolve with the same email at store2 — should find # the existing customer from store1 via the loyalty card join result = self.service.resolve_customer_id( db, customer_id=None, email=customer.email, store_id=store2.id, merchant_id=merchant.id, create_if_missing=True, ) assert result == customer.id def test_resolve_without_merchant_id_creates_new_customer( self, db, loyalty_store_setup ): """Without merchant_id, the cross-store lookup is skipped and a new customer is created at the new store.""" setup = loyalty_store_setup merchant = setup["merchant"] customer = setup["customer"] from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"NOMID_{uuid.uuid4().hex[:6].upper()}", subdomain=f"nomid{uuid.uuid4().hex[:6]}", name="No Merchant ID Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() result = self.service.resolve_customer_id( db, customer_id=None, email=customer.email, store_id=store2.id, # No merchant_id — cross-store lookup skipped create_if_missing=True, customer_name="New Customer", ) assert result != customer.id # Different customer created def test_enroll_cross_location_enabled_rejects_duplicate( self, db, loyalty_store_setup ): """With cross-location enabled (default), enrolling the same customer_id at a different store raises AlreadyExists.""" from app.modules.loyalty.exceptions import ( LoyaltyCardAlreadyExistsException, ) setup = loyalty_store_setup merchant = setup["merchant"] customer = setup["customer"] # Already has a card from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"DUP_{uuid.uuid4().hex[:6].upper()}", subdomain=f"dup{uuid.uuid4().hex[:6]}", name="Dup Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() with pytest.raises(LoyaltyCardAlreadyExistsException): self.service.enroll_customer_for_store( db, customer.id, store2.id ) def test_enroll_cross_location_disabled_allows_second_card( self, db, loyalty_store_setup ): """With cross-location disabled, the same customer can enroll at a different store and get a separate card.""" setup = loyalty_store_setup merchant = setup["merchant"] customer = setup["customer"] # Disable cross-location from app.modules.loyalty.services.program_service import ( program_service, ) settings = program_service.get_or_create_merchant_settings( db, merchant.id ) settings.allow_cross_location_redemption = False db.commit() from app.modules.tenancy.models import Store store2 = Store( merchant_id=merchant.id, store_code=f"XLOC_{uuid.uuid4().hex[:6].upper()}", subdomain=f"xloc{uuid.uuid4().hex[:6]}", name="Cross-Loc Store", is_active=True, is_verified=True, ) db.add(store2) db.commit() db.refresh(store2) # Should succeed — different store, cross-location disabled card2 = self.service.enroll_customer_for_store( db, customer.id, store2.id ) assert card2.enrolled_at_store_id == store2.id assert card2.merchant_id == merchant.id assert card2.customer_id == customer.id # Original card still exists assert setup["card"].id != card2.id def test_enroll_cross_location_disabled_rejects_same_store( self, db, loyalty_store_setup ): """With cross-location disabled, re-enrolling at the SAME store still raises AlreadyExists.""" from app.modules.loyalty.exceptions import ( LoyaltyCardAlreadyExistsException, ) setup = loyalty_store_setup merchant = setup["merchant"] customer = setup["customer"] from app.modules.loyalty.services.program_service import ( program_service, ) settings = program_service.get_or_create_merchant_settings( db, merchant.id ) settings.allow_cross_location_redemption = False db.commit() with pytest.raises(LoyaltyCardAlreadyExistsException): self.service.enroll_customer_for_store( db, customer.id, setup["store"].id )