From a4519035df3870563b4950ae8c0b187fa29b7057 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 24 Feb 2026 22:29:27 +0100 Subject: [PATCH] fix(loyalty): read Google Wallet config from core settings instead of module config Module config only reads from os.environ (not .env), so wallet settings were always None. Core Settings already loads these via env_file=".env". Also adds comprehensive wallet creation tests with mocked Google API. Co-Authored-By: Claude Opus 4.6 --- app/modules/loyalty/config.py | 4 - app/modules/loyalty/routes/api/store.py | 12 +- app/modules/loyalty/schemas/card.py | 3 + app/modules/loyalty/services/card_service.py | 5 +- .../loyalty/services/google_wallet_service.py | 12 +- .../loyalty/tests/unit/test_wallet_service.py | 417 +++++++++++++++++- 6 files changed, 437 insertions(+), 16 deletions(-) diff --git a/app/modules/loyalty/config.py b/app/modules/loyalty/config.py index d8297eb3..4e34b735 100644 --- a/app/modules/loyalty/config.py +++ b/app/modules/loyalty/config.py @@ -29,10 +29,6 @@ class ModuleConfig(BaseSettings): # Points configuration default_points_per_euro: int = 10 # 10 points per euro spent - # Google Wallet - google_issuer_id: str | None = None - google_service_account_json: str | None = None # Path to JSON file - # Apple Wallet apple_pass_type_id: str | None = None apple_team_id: str | None = None diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 1b075432..cf3cef55 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -478,10 +478,14 @@ def list_store_transactions( db, merchant_id, skip=skip, limit=limit ) - return TransactionListResponse( - transactions=[TransactionResponse.model_validate(t) for t in transactions], - total=total, - ) + tx_responses = [] + for t in transactions: + tx = TransactionResponse.model_validate(t) + if t.card and t.card.customer: + tx.customer_name = t.card.customer.full_name + tx_responses.append(tx) + + return TransactionListResponse(transactions=tx_responses, total=total) @store_router.post("/cards/enroll", response_model=CardResponse, status_code=201) diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index 684c8aa2..6bfb51e4 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -175,6 +175,9 @@ class TransactionResponse(BaseModel): reward_description: str | None = None notes: str | None = None + # Customer + customer_name: str | None = None + # Staff staff_name: str | None = None diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 67f8288c..b0e677fb 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -655,7 +655,10 @@ class CardService: query = ( db.query(LoyaltyTransaction) .join(LoyaltyCard, LoyaltyTransaction.card_id == LoyaltyCard.id) - .options(joinedload(LoyaltyTransaction.store)) + .options( + joinedload(LoyaltyTransaction.store), + joinedload(LoyaltyTransaction.card).joinedload(LoyaltyCard.customer), + ) .filter(LoyaltyCard.merchant_id == merchant_id) ) if store_id: diff --git a/app/modules/loyalty/services/google_wallet_service.py b/app/modules/loyalty/services/google_wallet_service.py index bc946059..67ea1d2c 100644 --- a/app/modules/loyalty/services/google_wallet_service.py +++ b/app/modules/loyalty/services/google_wallet_service.py @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy.orm import Session -from app.modules.loyalty.config import config +from app.core.config import settings from app.modules.loyalty.exceptions import ( GoogleWalletNotConfiguredException, WalletIntegrationException, @@ -35,14 +35,14 @@ class GoogleWalletService: @property def is_configured(self) -> bool: """Check if Google Wallet is configured.""" - return bool(config.google_issuer_id and config.google_service_account_json) + return bool(settings.loyalty_google_issuer_id and settings.loyalty_google_service_account_json) def _get_credentials(self): """Get Google service account credentials.""" if self._credentials: return self._credentials - if not config.google_service_account_json: + if not settings.loyalty_google_service_account_json: raise GoogleWalletNotConfiguredException() try: @@ -51,7 +51,7 @@ class GoogleWalletService: scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"] self._credentials = service_account.Credentials.from_service_account_file( - config.google_service_account_json, + settings.loyalty_google_service_account_json, scopes=scopes, ) return self._credentials @@ -92,7 +92,7 @@ class GoogleWalletService: if not self.is_configured: raise GoogleWalletNotConfiguredException() - issuer_id = config.google_issuer_id + issuer_id = settings.loyalty_google_issuer_id class_id = f"{issuer_id}.loyalty_program_{program.id}" class_data = { @@ -203,7 +203,7 @@ class GoogleWalletService: # Create class first self.create_class(db, program) - issuer_id = config.google_issuer_id + issuer_id = settings.loyalty_google_issuer_id object_id = f"{issuer_id}.loyalty_card_{card.id}" object_data = self._build_object_data(card, object_id) diff --git a/app/modules/loyalty/tests/unit/test_wallet_service.py b/app/modules/loyalty/tests/unit/test_wallet_service.py index dde1a219..0f435d88 100644 --- a/app/modules/loyalty/tests/unit/test_wallet_service.py +++ b/app/modules/loyalty/tests/unit/test_wallet_service.py @@ -1,8 +1,123 @@ -"""Unit tests for WalletService.""" +# app/modules/loyalty/tests/unit/test_wallet_service.py +""" +Unit tests for WalletService — wallet object creation during enrollment. + +Tests that: +- Google Wallet objects are created and DB fields populated on enrollment +- Apple Wallet serial numbers are set when apple_pass_type_id is configured +- Wallet creation failures don't crash enrollment +- Wallet creation is skipped when not configured +""" + +import uuid +from unittest.mock import MagicMock, patch import pytest +from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram +from app.modules.loyalty.models.loyalty_program import LoyaltyType from app.modules.loyalty.services.wallet_service import WalletService +from app.modules.tenancy.models import Merchant + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def wt_merchant(db): + """Create a merchant for wallet tests.""" + merchant = Merchant( + name=f"Wallet Test Merchant {uuid.uuid4().hex[:8]}", + contact_email=f"wallet_{uuid.uuid4().hex[:8]}@test.com", + is_active=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def wt_program(db, wt_merchant): + """Create a loyalty program for wallet tests.""" + program = LoyaltyProgram( + merchant_id=wt_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=10, + welcome_bonus_points=0, + minimum_redemption_points=100, + cooldown_minutes=0, + max_daily_stamps=5, + require_staff_pin=False, + card_name="Wallet Test Rewards", + card_color="#4F46E5", + is_active=True, + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +@pytest.fixture +def wt_program_with_apple(db, wt_merchant): + """Create a loyalty program with Apple Wallet configured.""" + program = LoyaltyProgram( + merchant_id=wt_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=10, + welcome_bonus_points=0, + minimum_redemption_points=100, + cooldown_minutes=0, + max_daily_stamps=5, + require_staff_pin=False, + card_name="Apple Wallet Test", + card_color="#000000", + apple_pass_type_id="pass.com.test.loyalty", + is_active=True, + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +@pytest.fixture +def wt_card(db, wt_program): + """Create a loyalty card for wallet tests.""" + card = LoyaltyCard( + merchant_id=wt_program.merchant_id, + program_id=wt_program.id, + customer_id=None, + card_number=f"WT-{uuid.uuid4().hex[:8].upper()}", + is_active=True, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +@pytest.fixture +def wt_card_apple(db, wt_program_with_apple): + """Create a loyalty card for Apple Wallet tests.""" + card = LoyaltyCard( + merchant_id=wt_program_with_apple.merchant_id, + program_id=wt_program_with_apple.id, + customer_id=None, + card_number=f"WTA-{uuid.uuid4().hex[:8].upper()}", + is_active=True, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +# ============================================================================ +# WalletService instantiation +# ============================================================================ @pytest.mark.unit @@ -16,3 +131,303 @@ class TestWalletService: def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None + + +# ============================================================================ +# Google Wallet object creation +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestCreateWalletObjectsGoogle: + """Tests for Google Wallet object creation during enrollment.""" + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_object_created_when_configured( + self, mock_gw, db, wt_card + ): + """Google Wallet object is created when service is configured.""" + mock_gw.is_configured = True + mock_gw.create_object.return_value = "test_object_id" + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is True + mock_gw.create_object.assert_called_once_with(db, wt_card) + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_skipped_when_not_configured( + self, mock_gw, db, wt_card + ): + """Google Wallet is skipped when not configured.""" + mock_gw.is_configured = False + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is False + mock_gw.create_object.assert_not_called() + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_failure_does_not_crash( + self, mock_gw, db, wt_card + ): + """Enrollment continues even if Google Wallet creation fails.""" + mock_gw.is_configured = True + mock_gw.create_object.side_effect = Exception("Google API error") + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is False + # Card should still be valid + db.refresh(wt_card) + assert wt_card.is_active is True + + +# ============================================================================ +# Apple Wallet serial number setup +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestCreateWalletObjectsApple: + """Tests for Apple Wallet serial number assignment during enrollment.""" + + def test_apple_serial_number_set_when_configured(self, db, wt_card_apple): + """Apple serial number is set when program has apple_pass_type_id.""" + assert wt_card_apple.apple_serial_number is None + + service = WalletService() + with patch( + "app.modules.loyalty.services.wallet_service.google_wallet_service" + ) as mock_gw: + mock_gw.is_configured = False + results = service.create_wallet_objects(db, wt_card_apple) + + assert results["apple_wallet"] is True + db.refresh(wt_card_apple) + assert wt_card_apple.apple_serial_number is not None + assert wt_card_apple.apple_serial_number.startswith(f"card_{wt_card_apple.id}_") + + def test_apple_serial_number_not_set_when_unconfigured(self, db, wt_card): + """Apple serial number not set when program lacks apple_pass_type_id.""" + service = WalletService() + with patch( + "app.modules.loyalty.services.wallet_service.google_wallet_service" + ) as mock_gw: + mock_gw.is_configured = False + results = service.create_wallet_objects(db, wt_card) + + assert results["apple_wallet"] is False + db.refresh(wt_card) + assert wt_card.apple_serial_number is None + + +# ============================================================================ +# GoogleWalletService.create_object — DB field population +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestGoogleWalletCreateObject: + """Tests that GoogleWalletService.create_object populates DB fields correctly.""" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_object_sets_google_object_id(self, mock_settings, db, wt_card, wt_program): + """create_object sets card.google_object_id in the database.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + # Pre-set class_id on program so create_class isn't called + wt_program.google_class_id = "1234567890.loyalty_program_1" + db.commit() + + service = GoogleWalletService() + service._credentials = MagicMock() # Skip credential loading + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + service._http_client = mock_http + + result = service.create_object(db, wt_card) + + expected_object_id = f"1234567890.loyalty_card_{wt_card.id}" + assert result == expected_object_id + + db.refresh(wt_card) + assert wt_card.google_object_id == expected_object_id + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_object_handles_409_conflict(self, mock_settings, db, wt_card, wt_program): + """create_object handles 409 (already exists) by still setting the object_id.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + wt_program.google_class_id = "1234567890.loyalty_program_1" + db.commit() + + service = GoogleWalletService() + service._credentials = MagicMock() + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 409 # Already exists + mock_http.post.return_value = mock_response + service._http_client = mock_http + + service.create_object(db, wt_card) + + db.refresh(wt_card) + assert wt_card.google_object_id == f"1234567890.loyalty_card_{wt_card.id}" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_class_sets_google_class_id(self, mock_settings, db, wt_program): + """create_class sets program.google_class_id in the database.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + assert wt_program.google_class_id is None + + service = GoogleWalletService() + service._credentials = MagicMock() + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + service._http_client = mock_http + + result = service.create_class(db, wt_program) + + expected_class_id = f"1234567890.loyalty_program_{wt_program.id}" + assert result == expected_class_id + + db.refresh(wt_program) + assert wt_program.google_class_id == expected_class_id + + +# ============================================================================ +# End-to-end: enrollment → wallet creation +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestEnrollmentWalletCreation: + """Tests that enrollment triggers wallet object creation and populates DB fields.""" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_enrollment_creates_google_wallet_object( + self, mock_settings, db, wt_program, wt_merchant + ): + """Full enrollment flow creates Google Wallet class + object in DB.""" + from app.modules.loyalty.services.google_wallet_service import ( + google_wallet_service, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + # Mock the HTTP client to simulate Google API success + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + google_wallet_service._http_client = mock_http + google_wallet_service._credentials = MagicMock() + + # Create a test customer + from app.modules.customers.models import Customer + customer = Customer( + email=f"wallet_test_{uuid.uuid4().hex[:8]}@test.com", + first_name="Wallet", + last_name="Test", + is_active=True, + ) + db.add(customer) + db.commit() + + # Enroll via card_service + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer(db, customer.id, wt_merchant.id) + + # Verify wallet DB fields are populated + db.refresh(card) + assert card.google_object_id is not None + assert card.google_object_id == f"1234567890.loyalty_card_{card.id}" + + # Verify program class_id is set + db.refresh(wt_program) + assert wt_program.google_class_id is not None + assert wt_program.google_class_id == f"1234567890.loyalty_program_{wt_program.id}" + + # Clean up singleton state + google_wallet_service._http_client = None + google_wallet_service._credentials = None + + def test_enrollment_succeeds_without_wallet_config(self, db, wt_program, wt_merchant): + """Enrollment works even when Google Wallet is not configured.""" + from app.modules.customers.models import Customer + customer = Customer( + email=f"no_wallet_{uuid.uuid4().hex[:8]}@test.com", + first_name="No", + last_name="Wallet", + is_active=True, + ) + db.add(customer) + db.commit() + + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer(db, customer.id, wt_merchant.id) + + db.refresh(card) + assert card.id is not None + assert card.is_active is True + # Wallet fields should be None since not configured + assert card.google_object_id is None + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_enrollment_with_apple_wallet_sets_serial( + self, mock_settings, db, wt_program_with_apple + ): + """Enrollment sets apple_serial_number when program has apple_pass_type_id.""" + mock_settings.loyalty_google_issuer_id = None + mock_settings.loyalty_google_service_account_json = None + + from app.modules.customers.models import Customer + customer = Customer( + email=f"apple_test_{uuid.uuid4().hex[:8]}@test.com", + first_name="Apple", + last_name="Test", + is_active=True, + ) + db.add(customer) + db.commit() + + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer( + db, customer.id, wt_program_with_apple.merchant_id + ) + + db.refresh(card) + assert card.apple_serial_number is not None + assert card.apple_serial_number.startswith(f"card_{card.id}_")