feat(loyalty): Google Wallet production readiness — 10 hardening items
Some checks failed
Some checks failed
- 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:
@@ -217,14 +217,6 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# GOOGLE WALLET (LOYALTY MODULE)
|
|
||||||
# =============================================================================
|
|
||||||
loyalty_google_issuer_id: str | None = None
|
|
||||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
|
||||||
loyalty_google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
|
|
||||||
loyalty_default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png"
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# APPLE WALLET (LOYALTY MODULE)
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -234,7 +226,7 @@ class Settings(BaseSettings):
|
|||||||
loyalty_apple_signer_cert_path: str | None = None
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
loyalty_apple_signer_key_path: str | None = None
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ class ModuleConfig(BaseSettings):
|
|||||||
apple_signer_cert_path: str | None = None # Pass signing certificate
|
apple_signer_cert_path: str | None = None # Pass signing certificate
|
||||||
apple_signer_key_path: str | None = None # Pass signing key
|
apple_signer_key_path: str | None = None # Pass signing key
|
||||||
|
|
||||||
|
# Google Wallet
|
||||||
|
google_issuer_id: str | None = None
|
||||||
|
google_service_account_json: str | None = None # Path to service account JSON
|
||||||
|
google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
|
||||||
|
default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png"
|
||||||
|
|
||||||
# QR code settings
|
# QR code settings
|
||||||
qr_code_size: int = 300 # pixels
|
qr_code_size: int = 300 # pixels
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.modules.loyalty.schemas import (
|
|||||||
)
|
)
|
||||||
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
||||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
|
||||||
storefront_router = APIRouter()
|
storefront_router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,6 +38,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@storefront_router.get("/loyalty/program")
|
@storefront_router.get("/loyalty/program")
|
||||||
|
@rate_limit(max_requests=30, window_seconds=60)
|
||||||
def get_program_info(
|
def get_program_info(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -62,6 +64,7 @@ def get_program_info(
|
|||||||
|
|
||||||
|
|
||||||
@storefront_router.post("/loyalty/enroll")
|
@storefront_router.post("/loyalty/enroll")
|
||||||
|
@rate_limit(max_requests=10, window_seconds=60)
|
||||||
def self_enroll(
|
def self_enroll(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: CardEnrollRequest,
|
data: CardEnrollRequest,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.modules.loyalty.config import config
|
||||||
from app.modules.loyalty.exceptions import (
|
from app.modules.loyalty.exceptions import (
|
||||||
GoogleWalletNotConfiguredException,
|
GoogleWalletNotConfiguredException,
|
||||||
WalletIntegrationException,
|
WalletIntegrationException,
|
||||||
@@ -51,7 +51,7 @@ def _retry_on_failure(func):
|
|||||||
# Don't retry client errors (400, 401, 403, 404, 409)
|
# Don't retry client errors (400, 401, 403, 404, 409)
|
||||||
exc_msg = str(exc)
|
exc_msg = str(exc)
|
||||||
if any(f":{code}" in exc_msg or f" {code} " in exc_msg
|
if any(f":{code}" in exc_msg or f" {code} " in exc_msg
|
||||||
for code in ("400", "401", "403", "404")):
|
for code in ("400", "401", "403", "404", "409")):
|
||||||
logger.error("Google Wallet API client error (not retryable): %s", exc)
|
logger.error("Google Wallet API client error (not retryable): %s", exc)
|
||||||
break
|
break
|
||||||
if attempt < MAX_RETRIES - 1:
|
if attempt < MAX_RETRIES - 1:
|
||||||
@@ -63,6 +63,9 @@ def _retry_on_failure(func):
|
|||||||
wait,
|
wait,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
# Safe: all loyalty routes are sync def, so FastAPI runs them
|
||||||
|
# in a threadpool. time.sleep() only blocks the worker thread,
|
||||||
|
# not the async event loop.
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -83,13 +86,14 @@ class GoogleWalletService:
|
|||||||
self._credentials = None
|
self._credentials = None
|
||||||
self._http_client = None
|
self._http_client = None
|
||||||
self._signer = None
|
self._signer = None
|
||||||
|
self._private_key: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""Check if Google Wallet is configured."""
|
"""Check if Google Wallet is configured."""
|
||||||
return bool(
|
return bool(
|
||||||
settings.loyalty_google_issuer_id
|
config.google_issuer_id
|
||||||
and settings.loyalty_google_service_account_json
|
and config.google_service_account_json
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_config(self) -> dict[str, Any]:
|
def validate_config(self) -> dict[str, Any]:
|
||||||
@@ -103,8 +107,8 @@ class GoogleWalletService:
|
|||||||
|
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"configured": self.is_configured,
|
"configured": self.is_configured,
|
||||||
"issuer_id": settings.loyalty_google_issuer_id,
|
"issuer_id": config.google_issuer_id,
|
||||||
"service_account_path": settings.loyalty_google_service_account_json,
|
"service_account_path": config.google_service_account_json,
|
||||||
"credentials_valid": False,
|
"credentials_valid": False,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
@@ -112,7 +116,7 @@ class GoogleWalletService:
|
|||||||
if not self.is_configured:
|
if not self.is_configured:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
sa_path = settings.loyalty_google_service_account_json
|
sa_path = config.google_service_account_json
|
||||||
if not os.path.isfile(sa_path):
|
if not os.path.isfile(sa_path):
|
||||||
result["errors"].append(f"Service account file not found: {sa_path}")
|
result["errors"].append(f"Service account file not found: {sa_path}")
|
||||||
return result
|
return result
|
||||||
@@ -145,6 +149,21 @@ class GoogleWalletService:
|
|||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
result["errors"].append(f"Failed to load credentials: {exc}")
|
result["errors"].append(f"Failed to load credentials: {exc}")
|
||||||
|
|
||||||
|
# Check logo URL reachability (warning only, not a blocking error)
|
||||||
|
logo_url = config.default_logo_url
|
||||||
|
if logo_url:
|
||||||
|
result["warnings"] = result.get("warnings", [])
|
||||||
|
try:
|
||||||
|
resp = requests.head(logo_url, timeout=5, allow_redirects=True)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
result["warnings"].append(
|
||||||
|
f"Default logo URL returned HTTP {resp.status_code}: {logo_url}"
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
result["warnings"].append(
|
||||||
|
f"Default logo URL unreachable: {logo_url} ({exc})"
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_credentials(self):
|
def _get_credentials(self):
|
||||||
@@ -152,7 +171,7 @@ class GoogleWalletService:
|
|||||||
if self._credentials:
|
if self._credentials:
|
||||||
return self._credentials
|
return self._credentials
|
||||||
|
|
||||||
if not settings.loyalty_google_service_account_json:
|
if not config.google_service_account_json:
|
||||||
raise GoogleWalletNotConfiguredException()
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -162,7 +181,7 @@ class GoogleWalletService:
|
|||||||
|
|
||||||
self._credentials = (
|
self._credentials = (
|
||||||
service_account.Credentials.from_service_account_file(
|
service_account.Credentials.from_service_account_file(
|
||||||
settings.loyalty_google_service_account_json,
|
config.google_service_account_json,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -180,13 +199,23 @@ class GoogleWalletService:
|
|||||||
from google.auth.crypt import RSASigner
|
from google.auth.crypt import RSASigner
|
||||||
|
|
||||||
self._signer = RSASigner.from_service_account_file(
|
self._signer = RSASigner.from_service_account_file(
|
||||||
settings.loyalty_google_service_account_json,
|
config.google_service_account_json,
|
||||||
)
|
)
|
||||||
return self._signer
|
return self._signer
|
||||||
except (ValueError, OSError, KeyError) as exc:
|
except (ValueError, OSError, KeyError) as exc:
|
||||||
logger.error("Failed to create RSA signer: %s", exc)
|
logger.error("Failed to create RSA signer: %s", exc)
|
||||||
raise WalletIntegrationException("google", str(exc)) from exc
|
raise WalletIntegrationException("google", str(exc)) from exc
|
||||||
|
|
||||||
|
def _get_private_key(self) -> str:
|
||||||
|
"""Get the private key from the service account JSON, with caching."""
|
||||||
|
if self._private_key:
|
||||||
|
return self._private_key
|
||||||
|
|
||||||
|
with open(config.google_service_account_json) as f:
|
||||||
|
sa_data = json.load(f)
|
||||||
|
self._private_key = sa_data["private_key"]
|
||||||
|
return self._private_key
|
||||||
|
|
||||||
def _get_http_client(self):
|
def _get_http_client(self):
|
||||||
"""Get authenticated HTTP client."""
|
"""Get authenticated HTTP client."""
|
||||||
if self._http_client:
|
if self._http_client:
|
||||||
@@ -221,7 +250,7 @@ class GoogleWalletService:
|
|||||||
if not self.is_configured:
|
if not self.is_configured:
|
||||||
raise GoogleWalletNotConfiguredException()
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
issuer_id = settings.loyalty_google_issuer_id
|
issuer_id = config.google_issuer_id
|
||||||
class_id = f"{issuer_id}.loyalty_program_{program.id}"
|
class_id = f"{issuer_id}.loyalty_program_{program.id}"
|
||||||
|
|
||||||
# issuerName is required by Google Wallet API
|
# issuerName is required by Google Wallet API
|
||||||
@@ -246,7 +275,7 @@ class GoogleWalletService:
|
|||||||
# Google must be able to fetch the image, so it needs a public URL
|
# Google must be able to fetch the image, so it needs a public URL
|
||||||
logo_url = program.logo_url
|
logo_url = program.logo_url
|
||||||
if not logo_url:
|
if not logo_url:
|
||||||
logo_url = settings.loyalty_default_logo_url
|
logo_url = config.default_logo_url
|
||||||
class_data["programLogo"] = {
|
class_data["programLogo"] = {
|
||||||
"sourceUri": {"uri": logo_url},
|
"sourceUri": {"uri": logo_url},
|
||||||
}
|
}
|
||||||
@@ -289,10 +318,14 @@ class GoogleWalletService:
|
|||||||
raise WalletIntegrationException("google", str(exc)) from exc
|
raise WalletIntegrationException("google", str(exc)) from exc
|
||||||
|
|
||||||
@_retry_on_failure
|
@_retry_on_failure
|
||||||
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
def update_class(self, db: Session, program: LoyaltyProgram) -> bool:
|
||||||
"""Update a LoyaltyClass when program settings change."""
|
"""Update a LoyaltyClass when program settings change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded, False otherwise.
|
||||||
|
"""
|
||||||
if not program.google_class_id:
|
if not program.google_class_id:
|
||||||
return
|
return False
|
||||||
|
|
||||||
class_data = {
|
class_data = {
|
||||||
"programName": program.display_name,
|
"programName": program.display_name,
|
||||||
@@ -312,14 +345,17 @@ class GoogleWalletService:
|
|||||||
json=class_data,
|
json=class_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code in (200, 201):
|
||||||
logger.warning(
|
return True
|
||||||
"Failed to update Google Wallet class %s: %s",
|
logger.error(
|
||||||
program.google_class_id,
|
"Failed to update Google Wallet class %s: %s",
|
||||||
response.status_code,
|
program.google_class_id,
|
||||||
)
|
response.status_code,
|
||||||
|
)
|
||||||
|
return False
|
||||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||||
logger.error("Failed to update Google Wallet class: %s", exc)
|
logger.error("Failed to update Google Wallet class: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# LoyaltyObject Operations (Card-level)
|
# LoyaltyObject Operations (Card-level)
|
||||||
@@ -344,7 +380,7 @@ class GoogleWalletService:
|
|||||||
if not program.google_class_id:
|
if not program.google_class_id:
|
||||||
self.create_class(db, program)
|
self.create_class(db, program)
|
||||||
|
|
||||||
issuer_id = settings.loyalty_google_issuer_id
|
issuer_id = config.google_issuer_id
|
||||||
object_id = f"{issuer_id}.loyalty_card_{card.id}"
|
object_id = f"{issuer_id}.loyalty_card_{card.id}"
|
||||||
|
|
||||||
object_data = self._build_object_data(card, object_id)
|
object_data = self._build_object_data(card, object_id)
|
||||||
@@ -490,9 +526,9 @@ class GoogleWalletService:
|
|||||||
credentials = self._get_credentials()
|
credentials = self._get_credentials()
|
||||||
|
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
origins = settings.loyalty_google_wallet_origins or []
|
origins = config.google_wallet_origins or []
|
||||||
|
|
||||||
issuer_id = settings.loyalty_google_issuer_id
|
issuer_id = config.google_issuer_id
|
||||||
object_id = card.google_object_id or f"{issuer_id}.loyalty_card_{card.id}"
|
object_id = card.google_object_id or f"{issuer_id}.loyalty_card_{card.id}"
|
||||||
|
|
||||||
if card.google_object_id:
|
if card.google_object_id:
|
||||||
@@ -517,11 +553,8 @@ class GoogleWalletService:
|
|||||||
"exp": now + timedelta(hours=1),
|
"exp": now + timedelta(hours=1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load the private key directly from the service account file
|
# PyJWT needs the PEM string; cached after first load
|
||||||
# (RSASigner doesn't expose .key; PyJWT needs the PEM string)
|
private_key = self._get_private_key()
|
||||||
with open(settings.loyalty_google_service_account_json) as f:
|
|
||||||
sa_data = json.load(f)
|
|
||||||
private_key = sa_data["private_key"]
|
|
||||||
|
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
claims,
|
claims,
|
||||||
|
|||||||
@@ -66,20 +66,38 @@ def sync_wallet_passes() -> dict:
|
|||||||
|
|
||||||
google_synced = 0
|
google_synced = 0
|
||||||
apple_synced = 0
|
apple_synced = 0
|
||||||
|
failed_card_ids = []
|
||||||
|
|
||||||
for card in cards:
|
for card in cards:
|
||||||
try:
|
synced = False
|
||||||
results = wallet_service.sync_card_to_wallets(db, card)
|
for attempt in range(2): # 1 retry
|
||||||
if results.get("google_wallet"):
|
try:
|
||||||
google_synced += 1
|
results = wallet_service.sync_card_to_wallets(db, card)
|
||||||
if results.get("apple_wallet"):
|
if results.get("google_wallet"):
|
||||||
apple_synced += 1
|
google_synced += 1
|
||||||
except Exception as e:
|
if results.get("apple_wallet"):
|
||||||
logger.warning(f"Failed to sync card {card.id} to wallets: {e}")
|
apple_synced += 1
|
||||||
|
synced = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to sync card {card.id} (attempt 1/2), "
|
||||||
|
f"retrying in 2s: {e}"
|
||||||
|
)
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to sync card {card.id} after 2 attempts: {e}"
|
||||||
|
)
|
||||||
|
if not synced:
|
||||||
|
failed_card_ids.append(card.id)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Wallet sync complete: {len(cards)} cards checked, "
|
f"Wallet sync complete: {len(cards)} cards checked, "
|
||||||
f"{google_synced} Google, {apple_synced} Apple"
|
f"{google_synced} Google, {apple_synced} Apple, "
|
||||||
|
f"{len(failed_card_ids)} failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,6 +105,7 @@ def sync_wallet_passes() -> dict:
|
|||||||
"cards_checked": len(cards),
|
"cards_checked": len(cards),
|
||||||
"google_synced": google_synced,
|
"google_synced": google_synced,
|
||||||
"apple_synced": apple_synced,
|
"apple_synced": apple_synced,
|
||||||
|
"failed_card_ids": failed_card_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,18 +1,622 @@
|
|||||||
"""Unit tests for GoogleWalletService."""
|
"""Unit tests for GoogleWalletService.
|
||||||
|
|
||||||
|
Covers: validate_config, create_class, update_class, create_object,
|
||||||
|
_build_object_data, get_save_url, _retry_on_failure, get_class_status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.modules.loyalty.services.google_wallet_service import GoogleWalletService
|
from app.modules.loyalty.exceptions import (
|
||||||
|
GoogleWalletNotConfiguredException,
|
||||||
|
WalletIntegrationException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
|
GoogleWalletService,
|
||||||
|
_retry_on_failure,
|
||||||
|
)
|
||||||
|
|
||||||
|
MOCK_ISSUER_ID = "1234567890"
|
||||||
|
MOCK_SA_PATH = "/fake/service_account.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(issuer_id=MOCK_ISSUER_ID, sa_path=MOCK_SA_PATH):
|
||||||
|
"""Create a GoogleWalletService with mocked credentials and HTTP client."""
|
||||||
|
service = GoogleWalletService()
|
||||||
|
service._credentials = MagicMock()
|
||||||
|
service._credentials.service_account_email = "test@test.iam.gserviceaccount.com"
|
||||||
|
service._http_client = MagicMock()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(status_code, json_data=None, text="{}"):
|
||||||
|
"""Create a mock HTTP response."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.text = text
|
||||||
|
resp.json.return_value = json_data or {}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_program(program_id=1, class_id=None, stamps=False, points=True):
|
||||||
|
"""Create a mock LoyaltyProgram."""
|
||||||
|
program = MagicMock()
|
||||||
|
program.id = program_id
|
||||||
|
program.google_class_id = class_id
|
||||||
|
program.display_name = "Test Rewards"
|
||||||
|
program.card_color = "#4285F4"
|
||||||
|
program.logo_url = None
|
||||||
|
program.hero_image_url = None
|
||||||
|
program.merchant.name = "Test Merchant"
|
||||||
|
program.is_stamps_enabled = stamps
|
||||||
|
program.is_points_enabled = points
|
||||||
|
program.stamps_target = 10
|
||||||
|
return program
|
||||||
|
|
||||||
|
|
||||||
|
def _make_card(card_id=1, program=None, active=True, google_object_id=None):
|
||||||
|
"""Create a mock LoyaltyCard."""
|
||||||
|
card = MagicMock()
|
||||||
|
card.id = card_id
|
||||||
|
card.card_number = "LC-ABCD1234"
|
||||||
|
card.is_active = active
|
||||||
|
card.stamp_count = 3
|
||||||
|
card.points_balance = 150
|
||||||
|
card.google_object_id = google_object_id
|
||||||
|
card.google_object_jwt = None
|
||||||
|
card.program = program or _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestValidateConfig
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.loyalty
|
@pytest.mark.loyalty
|
||||||
class TestGoogleWalletService:
|
class TestValidateConfig:
|
||||||
"""Test suite for GoogleWalletService."""
|
"""Tests for validate_config()."""
|
||||||
|
|
||||||
def setup_method(self):
|
@patch("app.modules.loyalty.services.google_wallet_service.requests")
|
||||||
self.service = GoogleWalletService()
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_valid_service_account(self, mock_config, mock_requests):
|
||||||
|
"""Valid service account file results in credentials_valid: True."""
|
||||||
|
sa_data = {
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "test-project",
|
||||||
|
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n",
|
||||||
|
"client_email": "test@test.iam.gserviceaccount.com",
|
||||||
|
}
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(sa_data, f)
|
||||||
|
sa_path = f.name
|
||||||
|
|
||||||
def test_service_instantiation(self):
|
try:
|
||||||
"""Service can be instantiated."""
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
assert self.service is not None
|
mock_config.google_service_account_json = sa_path
|
||||||
|
mock_config.default_logo_url = ""
|
||||||
|
mock_requests.head.return_value = _mock_response(200)
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
service._credentials = MagicMock() # Skip real credential loading
|
||||||
|
|
||||||
|
result = service.validate_config()
|
||||||
|
|
||||||
|
assert result["configured"] is True
|
||||||
|
assert result["credentials_valid"] is True
|
||||||
|
assert result["errors"] == []
|
||||||
|
finally:
|
||||||
|
os.unlink(sa_path)
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_missing_file(self, mock_config):
|
||||||
|
"""Missing service account file produces error."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = "/nonexistent/path.json"
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
result = service.validate_config()
|
||||||
|
|
||||||
|
assert result["configured"] is True
|
||||||
|
assert result["credentials_valid"] is False
|
||||||
|
assert any("not found" in e for e in result["errors"])
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_invalid_json(self, mock_config):
|
||||||
|
"""Invalid JSON in service account file produces error."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write("not valid json {{{")
|
||||||
|
sa_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = sa_path
|
||||||
|
mock_config.default_logo_url = ""
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
result = service.validate_config()
|
||||||
|
|
||||||
|
assert result["credentials_valid"] is False
|
||||||
|
assert any("Invalid JSON" in e for e in result["errors"])
|
||||||
|
finally:
|
||||||
|
os.unlink(sa_path)
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_wrong_credential_type(self, mock_config):
|
||||||
|
"""Non-service_account type produces error."""
|
||||||
|
sa_data = {
|
||||||
|
"type": "authorized_user",
|
||||||
|
"project_id": "test",
|
||||||
|
"private_key": "fake",
|
||||||
|
"client_email": "test@test.com",
|
||||||
|
}
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(sa_data, f)
|
||||||
|
sa_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = sa_path
|
||||||
|
mock_config.default_logo_url = ""
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
result = service.validate_config()
|
||||||
|
|
||||||
|
assert any("Invalid credential type" in e for e in result["errors"])
|
||||||
|
finally:
|
||||||
|
os.unlink(sa_path)
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_missing_required_fields(self, mock_config):
|
||||||
|
"""Missing required fields produce errors."""
|
||||||
|
sa_data = {"type": "service_account"} # Missing project_id, private_key, client_email
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(sa_data, f)
|
||||||
|
sa_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = sa_path
|
||||||
|
mock_config.default_logo_url = ""
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
result = service.validate_config()
|
||||||
|
|
||||||
|
assert result["credentials_valid"] is False
|
||||||
|
assert len(result["errors"]) >= 3 # project_id, private_key, client_email
|
||||||
|
finally:
|
||||||
|
os.unlink(sa_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestCreateClass
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestCreateClass:
|
||||||
|
"""Tests for create_class()."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_201_creates_class(self, mock_config):
|
||||||
|
"""201 response sets google_class_id and returns class_id."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.default_logo_url = "https://example.com/logo.png"
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.post.return_value = _mock_response(201)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program()
|
||||||
|
|
||||||
|
result = service.create_class(db, program)
|
||||||
|
|
||||||
|
expected_id = f"{MOCK_ISSUER_ID}.loyalty_program_{program.id}"
|
||||||
|
assert result == expected_id
|
||||||
|
assert program.google_class_id == expected_id
|
||||||
|
db.commit.assert_called()
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_409_still_sets_class_id(self, mock_config):
|
||||||
|
"""409 (already exists) still sets class_id — idempotent."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.default_logo_url = "https://example.com/logo.png"
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.post.return_value = _mock_response(409)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program()
|
||||||
|
|
||||||
|
result = service.create_class(db, program)
|
||||||
|
|
||||||
|
expected_id = f"{MOCK_ISSUER_ID}.loyalty_program_{program.id}"
|
||||||
|
assert result == expected_id
|
||||||
|
assert program.google_class_id == expected_id
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_500_raises_exception(self, mock_config):
|
||||||
|
"""500 response raises WalletIntegrationException."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.default_logo_url = "https://example.com/logo.png"
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.post.return_value = _mock_response(500, {"error": "internal"})
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program()
|
||||||
|
|
||||||
|
with pytest.raises(WalletIntegrationException):
|
||||||
|
service.create_class(db, program)
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_not_configured_raises(self, mock_config):
|
||||||
|
"""Raises GoogleWalletNotConfiguredException when not configured."""
|
||||||
|
mock_config.google_issuer_id = None
|
||||||
|
mock_config.google_service_account_json = None
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program()
|
||||||
|
|
||||||
|
with pytest.raises(GoogleWalletNotConfiguredException):
|
||||||
|
service.create_class(db, program)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestUpdateClass
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestUpdateClass:
|
||||||
|
"""Tests for update_class()."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_200_returns_true(self, mock_config):
|
||||||
|
"""200 response returns True."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.patch.return_value = _mock_response(200)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
|
||||||
|
|
||||||
|
assert service.update_class(db, program) is True
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_non_200_returns_false(self, mock_config):
|
||||||
|
"""Non-200 response returns False and logs error."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.patch.return_value = _mock_response(500)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
|
||||||
|
|
||||||
|
assert service.update_class(db, program) is False
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_no_class_id_returns_false(self, mock_config):
|
||||||
|
"""Returns False early when program has no google_class_id."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program(class_id=None)
|
||||||
|
|
||||||
|
assert service.update_class(db, program) is False
|
||||||
|
service._http_client.patch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestCreateObject
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestCreateObject:
|
||||||
|
"""Tests for create_object()."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_auto_creates_class_when_none(self, mock_config):
|
||||||
|
"""Auto-creates class when program.google_class_id is None."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.default_logo_url = "https://example.com/logo.png"
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
# First call creates class (201), second creates object (201)
|
||||||
|
service._http_client.post.side_effect = [
|
||||||
|
_mock_response(201), # create_class
|
||||||
|
_mock_response(201), # create_object
|
||||||
|
]
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
program = _make_program(class_id=None)
|
||||||
|
card = _make_card(program=program)
|
||||||
|
|
||||||
|
result = service.create_object(db, card)
|
||||||
|
|
||||||
|
assert result == f"{MOCK_ISSUER_ID}.loyalty_card_{card.id}"
|
||||||
|
assert service._http_client.post.call_count == 2
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.time")
|
||||||
|
def test_500_retried_via_decorator(self, mock_time, mock_config):
|
||||||
|
"""500 error triggers retry via _retry_on_failure decorator."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
|
||||||
|
card = _make_card(program=program)
|
||||||
|
|
||||||
|
# First call 500, second 201
|
||||||
|
service._http_client.post.side_effect = [
|
||||||
|
_mock_response(500, {"error": "transient"}),
|
||||||
|
_mock_response(201),
|
||||||
|
]
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
result = service.create_object(db, card)
|
||||||
|
|
||||||
|
assert result == f"{MOCK_ISSUER_ID}.loyalty_card_{card.id}"
|
||||||
|
assert service._http_client.post.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestBuildObjectData
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestBuildObjectData:
|
||||||
|
"""Tests for _build_object_data()."""
|
||||||
|
|
||||||
|
def test_stamps_program(self):
|
||||||
|
"""Stamps program includes loyaltyPoints + secondaryLoyaltyPoints."""
|
||||||
|
service = GoogleWalletService()
|
||||||
|
program = _make_program(stamps=True, points=False)
|
||||||
|
program.stamps_target = 10
|
||||||
|
card = _make_card(program=program)
|
||||||
|
card.stamp_count = 3
|
||||||
|
|
||||||
|
data = service._build_object_data(card, "test_obj_1")
|
||||||
|
|
||||||
|
assert data["loyaltyPoints"]["label"] == "Stamps"
|
||||||
|
assert data["loyaltyPoints"]["balance"]["int"] == 3
|
||||||
|
assert "secondaryLoyaltyPoints" in data
|
||||||
|
assert data["secondaryLoyaltyPoints"]["balance"]["int"] == 10
|
||||||
|
|
||||||
|
def test_points_program(self):
|
||||||
|
"""Points program includes loyaltyPoints only, no secondary."""
|
||||||
|
service = GoogleWalletService()
|
||||||
|
program = _make_program(stamps=False, points=True)
|
||||||
|
card = _make_card(program=program)
|
||||||
|
card.points_balance = 150
|
||||||
|
|
||||||
|
data = service._build_object_data(card, "test_obj_1")
|
||||||
|
|
||||||
|
assert data["loyaltyPoints"]["label"] == "Points"
|
||||||
|
assert data["loyaltyPoints"]["balance"]["int"] == 150
|
||||||
|
assert "secondaryLoyaltyPoints" not in data
|
||||||
|
|
||||||
|
def test_inactive_card(self):
|
||||||
|
"""Inactive card has state INACTIVE."""
|
||||||
|
service = GoogleWalletService()
|
||||||
|
card = _make_card(active=False)
|
||||||
|
|
||||||
|
data = service._build_object_data(card, "test_obj_1")
|
||||||
|
|
||||||
|
assert data["state"] == "INACTIVE"
|
||||||
|
|
||||||
|
def test_barcode_strips_dashes(self):
|
||||||
|
"""Barcode value has dashes removed from card_number."""
|
||||||
|
service = GoogleWalletService()
|
||||||
|
card = _make_card()
|
||||||
|
card.card_number = "LC-ABCD-1234"
|
||||||
|
|
||||||
|
data = service._build_object_data(card, "test_obj_1")
|
||||||
|
|
||||||
|
assert data["barcode"]["value"] == "LCABCD1234"
|
||||||
|
assert data["barcode"]["alternateText"] == "LC-ABCD-1234"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestGetSaveUrl
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestGetSaveUrl:
|
||||||
|
"""Tests for get_save_url()."""
|
||||||
|
|
||||||
|
@patch("jwt.encode", return_value="test_token")
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_existing_object_references_by_id(self, mock_config, mock_jwt_encode):
|
||||||
|
"""When object exists, JWT payload references by ID only."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.google_wallet_origins = ["https://example.com"]
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._private_key = "fake-key"
|
||||||
|
|
||||||
|
card = _make_card(google_object_id=f"{MOCK_ISSUER_ID}.loyalty_card_1")
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
url = service.get_save_url(db, card)
|
||||||
|
|
||||||
|
assert url == "https://pay.google.com/gp/v/save/test_token"
|
||||||
|
claims = mock_jwt_encode.call_args[0][0]
|
||||||
|
assert claims["payload"]["loyaltyObjects"][0] == {"id": card.google_object_id}
|
||||||
|
|
||||||
|
@patch("jwt.encode", return_value="fat_token")
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_object_creation_fails_uses_fat_jwt(self, mock_config, mock_jwt_encode):
|
||||||
|
"""When object creation fails, JWT embeds full object data."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.google_wallet_origins = []
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._private_key = "fake-key"
|
||||||
|
# create_object will fail
|
||||||
|
service._http_client.post.return_value = _mock_response(500, {"error": "fail"})
|
||||||
|
|
||||||
|
card = _make_card(google_object_id=None)
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
url = service.get_save_url(db, card)
|
||||||
|
|
||||||
|
assert "fat_token" in url
|
||||||
|
claims = mock_jwt_encode.call_args[0][0]
|
||||||
|
obj = claims["payload"]["loyaltyObjects"][0]
|
||||||
|
assert "classId" in obj
|
||||||
|
|
||||||
|
@patch("jwt.encode", return_value="token")
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_jwt_claims_structure(self, mock_config, mock_jwt_encode):
|
||||||
|
"""JWT claims include iss, aud, origins, typ, exp."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
mock_config.google_wallet_origins = ["https://app.example.com"]
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._private_key = "fake-key"
|
||||||
|
|
||||||
|
card = _make_card(google_object_id=f"{MOCK_ISSUER_ID}.loyalty_card_1")
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
service.get_save_url(db, card)
|
||||||
|
|
||||||
|
claims = mock_jwt_encode.call_args[0][0]
|
||||||
|
assert claims["iss"] == "test@test.iam.gserviceaccount.com"
|
||||||
|
assert claims["aud"] == "google"
|
||||||
|
assert claims["origins"] == ["https://app.example.com"]
|
||||||
|
assert claims["typ"] == "savetowallet"
|
||||||
|
assert "iat" in claims
|
||||||
|
assert "exp" in claims
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestRetryDecorator
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestRetryDecorator:
|
||||||
|
"""Tests for _retry_on_failure decorator."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.time")
|
||||||
|
def test_succeeds_first_try(self, mock_time):
|
||||||
|
"""No retry when function succeeds on first try."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
|
def always_succeed():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
result = always_succeed()
|
||||||
|
|
||||||
|
assert result == "ok"
|
||||||
|
assert call_count == 1
|
||||||
|
mock_time.sleep.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.time")
|
||||||
|
def test_retries_on_500_then_succeeds(self, mock_time):
|
||||||
|
"""Retries on 500 error and succeeds on second attempt."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
|
def fail_then_succeed():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise WalletIntegrationException("google", "Server error 500 ")
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
result = fail_then_succeed()
|
||||||
|
|
||||||
|
assert result == "ok"
|
||||||
|
assert call_count == 2
|
||||||
|
mock_time.sleep.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.time")
|
||||||
|
def test_404_not_retried(self, mock_time):
|
||||||
|
"""404 error is not retried — immediate failure."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
|
def always_404():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise WalletIntegrationException("google", "Not found: 404 error")
|
||||||
|
|
||||||
|
with pytest.raises(WalletIntegrationException):
|
||||||
|
always_404()
|
||||||
|
|
||||||
|
assert call_count == 1
|
||||||
|
mock_time.sleep.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TestGetClassStatus
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestGetClassStatus:
|
||||||
|
"""Tests for get_class_status()."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_200_returns_status(self, mock_config):
|
||||||
|
"""200 response returns dict with review_status."""
|
||||||
|
mock_config.google_issuer_id = MOCK_ISSUER_ID
|
||||||
|
mock_config.google_service_account_json = MOCK_SA_PATH
|
||||||
|
|
||||||
|
service = _make_service()
|
||||||
|
service._http_client.get.return_value = _mock_response(
|
||||||
|
200,
|
||||||
|
{"reviewStatus": "APPROVED", "programName": "Test Rewards"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.get_class_status("test_class_id")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["review_status"] == "APPROVED"
|
||||||
|
assert result["class_id"] == "test_class_id"
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
|
def test_not_configured_returns_none(self, mock_config):
|
||||||
|
"""Returns None when Google Wallet is not configured."""
|
||||||
|
mock_config.google_issuer_id = None
|
||||||
|
mock_config.google_service_account_json = None
|
||||||
|
|
||||||
|
service = GoogleWalletService()
|
||||||
|
result = service.get_class_status("test_class_id")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|||||||
@@ -224,15 +224,15 @@ class TestCreateWalletObjectsApple:
|
|||||||
class TestGoogleWalletCreateObject:
|
class TestGoogleWalletCreateObject:
|
||||||
"""Tests that GoogleWalletService.create_object populates DB fields correctly."""
|
"""Tests that GoogleWalletService.create_object populates DB fields correctly."""
|
||||||
|
|
||||||
@patch("app.modules.loyalty.services.google_wallet_service.settings")
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
def test_create_object_sets_google_object_id(self, mock_settings, db, wt_card, wt_program):
|
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."""
|
"""create_object sets card.google_object_id in the database."""
|
||||||
from app.modules.loyalty.services.google_wallet_service import (
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
GoogleWalletService,
|
GoogleWalletService,
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_settings.loyalty_google_issuer_id = "1234567890"
|
mock_config.google_issuer_id = "1234567890"
|
||||||
mock_settings.loyalty_google_service_account_json = "/fake/path.json"
|
mock_config.google_service_account_json = "/fake/path.json"
|
||||||
|
|
||||||
# Pre-set class_id on program so create_class isn't called
|
# Pre-set class_id on program so create_class isn't called
|
||||||
wt_program.google_class_id = "1234567890.loyalty_program_1"
|
wt_program.google_class_id = "1234567890.loyalty_program_1"
|
||||||
@@ -255,15 +255,15 @@ class TestGoogleWalletCreateObject:
|
|||||||
db.refresh(wt_card)
|
db.refresh(wt_card)
|
||||||
assert wt_card.google_object_id == expected_object_id
|
assert wt_card.google_object_id == expected_object_id
|
||||||
|
|
||||||
@patch("app.modules.loyalty.services.google_wallet_service.settings")
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
def test_create_object_handles_409_conflict(self, mock_settings, db, wt_card, wt_program):
|
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."""
|
"""create_object handles 409 (already exists) by still setting the object_id."""
|
||||||
from app.modules.loyalty.services.google_wallet_service import (
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
GoogleWalletService,
|
GoogleWalletService,
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_settings.loyalty_google_issuer_id = "1234567890"
|
mock_config.google_issuer_id = "1234567890"
|
||||||
mock_settings.loyalty_google_service_account_json = "/fake/path.json"
|
mock_config.google_service_account_json = "/fake/path.json"
|
||||||
|
|
||||||
wt_program.google_class_id = "1234567890.loyalty_program_1"
|
wt_program.google_class_id = "1234567890.loyalty_program_1"
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -282,15 +282,15 @@ class TestGoogleWalletCreateObject:
|
|||||||
db.refresh(wt_card)
|
db.refresh(wt_card)
|
||||||
assert wt_card.google_object_id == f"1234567890.loyalty_card_{wt_card.id}"
|
assert wt_card.google_object_id == f"1234567890.loyalty_card_{wt_card.id}"
|
||||||
|
|
||||||
@patch("app.modules.loyalty.services.google_wallet_service.settings")
|
@patch("app.modules.loyalty.services.google_wallet_service.config")
|
||||||
def test_create_class_sets_google_class_id(self, mock_settings, db, wt_program):
|
def test_create_class_sets_google_class_id(self, mock_config, db, wt_program):
|
||||||
"""create_class sets program.google_class_id in the database."""
|
"""create_class sets program.google_class_id in the database."""
|
||||||
from app.modules.loyalty.services.google_wallet_service import (
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
GoogleWalletService,
|
GoogleWalletService,
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_settings.loyalty_google_issuer_id = "1234567890"
|
mock_config.google_issuer_id = "1234567890"
|
||||||
mock_settings.loyalty_google_service_account_json = "/fake/path.json"
|
mock_config.google_service_account_json = "/fake/path.json"
|
||||||
|
|
||||||
assert wt_program.google_class_id is None
|
assert wt_program.google_class_id is None
|
||||||
|
|
||||||
@@ -322,17 +322,17 @@ class TestGoogleWalletCreateObject:
|
|||||||
class TestEnrollmentWalletCreation:
|
class TestEnrollmentWalletCreation:
|
||||||
"""Tests that enrollment triggers wallet object creation and populates DB fields."""
|
"""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(
|
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."""
|
"""Full enrollment flow creates Google Wallet class + object in DB."""
|
||||||
from app.modules.loyalty.services.google_wallet_service import (
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
google_wallet_service,
|
google_wallet_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_settings.loyalty_google_issuer_id = "1234567890"
|
mock_config.google_issuer_id = "1234567890"
|
||||||
mock_settings.loyalty_google_service_account_json = "/fake/path.json"
|
mock_config.google_service_account_json = "/fake/path.json"
|
||||||
|
|
||||||
# Mock the HTTP client to simulate Google API success
|
# Mock the HTTP client to simulate Google API success
|
||||||
mock_http = MagicMock()
|
mock_http = MagicMock()
|
||||||
@@ -373,13 +373,13 @@ class TestEnrollmentWalletCreation:
|
|||||||
# Wallet fields should be None since not configured
|
# Wallet fields should be None since not configured
|
||||||
assert card.google_object_id is None
|
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(
|
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."""
|
"""Enrollment sets apple_serial_number when program has apple_pass_type_id."""
|
||||||
mock_settings.loyalty_google_issuer_id = None
|
mock_config.google_issuer_id = None
|
||||||
mock_settings.loyalty_google_service_account_json = None
|
mock_config.google_service_account_json = None
|
||||||
|
|
||||||
from app.modules.loyalty.services.card_service import card_service
|
from app.modules.loyalty.services.card_service import card_service
|
||||||
card = card_service.enroll_customer(
|
card = card_service.enroll_customer(
|
||||||
|
|||||||
@@ -8,31 +8,78 @@ This module provides classes and functions for:
|
|||||||
- Consistent error handling for rate limit violations
|
- Consistent error handling for rate limit violations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from app.exceptions.base import RateLimitException # Add this import
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.exceptions.base import RateLimitException
|
||||||
|
from middleware.cloudflare import get_real_client_ip
|
||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
|
|
||||||
# Initialize rate limiter instance
|
# Initialize rate limiter instance
|
||||||
rate_limiter = RateLimiter()
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_request(*args, **kwargs) -> Request | None:
|
||||||
|
"""Extract a Request object from function args/kwargs."""
|
||||||
|
# Check kwargs first (FastAPI usually passes request= as keyword)
|
||||||
|
for val in kwargs.values():
|
||||||
|
if isinstance(val, Request):
|
||||||
|
return val
|
||||||
|
# Check positional args (e.g. self, request, ...)
|
||||||
|
for val in args:
|
||||||
|
if isinstance(val, Request):
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
|
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
|
||||||
"""Rate limiting decorator for FastAPI endpoints."""
|
"""Rate limiting decorator for FastAPI endpoints.
|
||||||
|
|
||||||
|
Works with both sync and async endpoint functions.
|
||||||
|
Extracts the real client IP from the Request object for per-client limiting.
|
||||||
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
if asyncio.iscoroutinefunction(func):
|
||||||
async def wrapper(*args, **kwargs):
|
|
||||||
client_id = "anonymous" # In production, extract from request
|
|
||||||
|
|
||||||
if not rate_limiter.allow_request(client_id, max_requests, window_seconds):
|
@wraps(func)
|
||||||
# Use custom exception instead of HTTPException
|
async def async_wrapper(*args, **kwargs):
|
||||||
raise RateLimitException(
|
request = _find_request(*args, **kwargs)
|
||||||
message="Rate limit exceeded", retry_after=window_seconds
|
client_id = (
|
||||||
|
get_real_client_ip(request) if request else "anonymous"
|
||||||
)
|
)
|
||||||
|
|
||||||
return await func(*args, **kwargs)
|
if not rate_limiter.allow_request(
|
||||||
|
client_id, max_requests, window_seconds
|
||||||
|
):
|
||||||
|
raise RateLimitException(
|
||||||
|
message="Rate limit exceeded",
|
||||||
|
retry_after=window_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
return wrapper
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
return async_wrapper
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
request = _find_request(*args, **kwargs)
|
||||||
|
client_id = (
|
||||||
|
get_real_client_ip(request) if request else "anonymous"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rate_limiter.allow_request(
|
||||||
|
client_id, max_requests, window_seconds
|
||||||
|
):
|
||||||
|
raise RateLimitException(
|
||||||
|
message="Rate limit exceeded",
|
||||||
|
retry_after=window_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
Reference in New Issue
Block a user