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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 22:29:27 +01:00
parent c9b2ecbdff
commit a4519035df
6 changed files with 437 additions and 16 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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}_")