feat(loyalty): Google Wallet production readiness — 10 hardening items
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 30s
CI / pytest (push) Failing after 3h9m5s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Fix rate limiter to extract real client IP and handle sync/async endpoints
- Rate-limit public enrollment (10/min) and program info (30/min) endpoints
- Add 409 Conflict to non-retryable status codes in retry decorator
- Cache private key in get_save_url() to avoid re-reading JSON per call
- Make update_class() return bool success status with error-level logging
- Move Google Wallet config from core to loyalty module config
- Document time.sleep() safety in retry decorator (threadpool execution)
- Add per-card retry (1 retry, 2s delay) to wallet sync task
- Add logo URL reachability check (HEAD request) to validate_config()
- Add 26 comprehensive unit tests for GoogleWalletService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 00:18:13 +01:00
parent 366d4b9765
commit b6047f5b7d
8 changed files with 791 additions and 87 deletions

View File

@@ -224,15 +224,15 @@ class TestCreateWalletObjectsApple:
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):
@patch("app.modules.loyalty.services.google_wallet_service.config")
def test_create_object_sets_google_object_id(self, mock_config, 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"
mock_config.google_issuer_id = "1234567890"
mock_config.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"
@@ -255,15 +255,15 @@ class TestGoogleWalletCreateObject:
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):
@patch("app.modules.loyalty.services.google_wallet_service.config")
def test_create_object_handles_409_conflict(self, mock_config, 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"
mock_config.google_issuer_id = "1234567890"
mock_config.google_service_account_json = "/fake/path.json"
wt_program.google_class_id = "1234567890.loyalty_program_1"
db.commit()
@@ -282,15 +282,15 @@ class TestGoogleWalletCreateObject:
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):
@patch("app.modules.loyalty.services.google_wallet_service.config")
def test_create_class_sets_google_class_id(self, mock_config, 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"
mock_config.google_issuer_id = "1234567890"
mock_config.google_service_account_json = "/fake/path.json"
assert wt_program.google_class_id is None
@@ -322,17 +322,17 @@ class TestGoogleWalletCreateObject:
class TestEnrollmentWalletCreation:
"""Tests that enrollment triggers wallet object creation and populates DB fields."""
@patch("app.modules.loyalty.services.google_wallet_service.settings")
@patch("app.modules.loyalty.services.google_wallet_service.config")
def test_enrollment_creates_google_wallet_object(
self, mock_settings, db, wt_program, test_merchant, test_customer
self, mock_config, db, wt_program, test_merchant, test_customer
):
"""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_config.google_issuer_id = "1234567890"
mock_config.google_service_account_json = "/fake/path.json"
# Mock the HTTP client to simulate Google API success
mock_http = MagicMock()
@@ -373,13 +373,13 @@ class TestEnrollmentWalletCreation:
# Wallet fields should be None since not configured
assert card.google_object_id is None
@patch("app.modules.loyalty.services.google_wallet_service.settings")
@patch("app.modules.loyalty.services.google_wallet_service.config")
def test_enrollment_with_apple_wallet_sets_serial(
self, mock_settings, db, wt_program_with_apple, test_customer
self, mock_config, db, wt_program_with_apple, test_customer
):
"""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
mock_config.google_issuer_id = None
mock_config.google_service_account_json = None
from app.modules.loyalty.services.card_service import card_service
card = card_service.enroll_customer(