feat(loyalty): production readiness round 2 — 12 security, integrity & correctness fixes
Some checks failed
Some checks failed
Security: - Fix TOCTOU race conditions: move balance/limit checks after row lock in redeem_points, add_stamp, redeem_stamps - Add PIN ownership verification to update/delete/unlock store routes - Gate adjust_points endpoint to merchant_owner role only Data integrity: - Track total_points_voided in void_points - Add order_reference idempotency guard in earn_points Correctness: - Fix LoyaltyProgramAlreadyExistsException to use merchant_id parameter - Add StorefrontProgramResponse excluding wallet IDs from public API - Add bounds (±100000) to PointsAdjustRequest.points_delta Audit & config: - Add CARD_REACTIVATED transaction type with audit record - Improve admin audit logging with actor identity and old values - Use merchant-specific PIN lockout settings with global fallback - Guard MerchantLoyaltySettings creation with get_or_create pattern Tests: 27 new tests (265 total) covering all 12 items — unit and integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,11 +43,11 @@ class LoyaltyProgramNotFoundException(ResourceNotFoundException):
|
||||
class LoyaltyProgramAlreadyExistsException(ConflictException):
|
||||
"""Raised when store already has a loyalty program."""
|
||||
|
||||
def __init__(self, store_id: int):
|
||||
def __init__(self, merchant_id: int):
|
||||
super().__init__(
|
||||
message=f"Store {store_id} already has a loyalty program",
|
||||
message=f"Merchant {merchant_id} already has a loyalty program",
|
||||
error_code="LOYALTY_PROGRAM_ALREADY_EXISTS",
|
||||
details={"store_id": store_id},
|
||||
details={"merchant_id": merchant_id},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class TransactionType(str, enum.Enum):
|
||||
# Card lifecycle
|
||||
CARD_CREATED = "card_created"
|
||||
CARD_DEACTIVATED = "card_deactivated"
|
||||
CARD_REACTIVATED = "card_reactivated"
|
||||
|
||||
# Bonuses and expiration
|
||||
WELCOME_BONUS = "welcome_bonus" # Welcome bonus points on enrollment
|
||||
|
||||
@@ -157,7 +157,9 @@ def delete_program(
|
||||
):
|
||||
"""Delete a loyalty program (admin override)."""
|
||||
program_service.delete_program(db, program_id)
|
||||
logger.info(f"Admin deleted loyalty program {program_id}")
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) deleted loyalty program {program_id}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/programs/{program_id}/activate", response_model=ProgramResponse)
|
||||
@@ -236,13 +238,20 @@ def update_merchant_settings(
|
||||
settings = program_service.get_or_create_merchant_settings(db, merchant_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Capture old values before overwrite for audit trail
|
||||
old_values = {field: getattr(settings, field) for field in update_data}
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Updated merchant {merchant_id} loyalty settings: {list(update_data.keys())}")
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) updated merchant {merchant_id} "
|
||||
f"loyalty settings: {list(update_data.keys())} (old: {old_values})"
|
||||
)
|
||||
|
||||
return MerchantSettingsResponse.model_validate(settings)
|
||||
|
||||
|
||||
@@ -244,6 +244,10 @@ def update_pin(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a staff PIN."""
|
||||
pin = pin_service.require_pin(db, pin_id)
|
||||
if pin.store_id != current_user.token_store_id:
|
||||
from app.modules.loyalty.exceptions import StaffPinNotFoundException
|
||||
raise StaffPinNotFoundException(str(pin_id))
|
||||
pin = pin_service.update_pin(db, pin_id, data)
|
||||
return PinResponse.model_validate(pin)
|
||||
|
||||
@@ -255,6 +259,10 @@ def delete_pin(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a staff PIN."""
|
||||
pin = pin_service.require_pin(db, pin_id)
|
||||
if pin.store_id != current_user.token_store_id:
|
||||
from app.modules.loyalty.exceptions import StaffPinNotFoundException
|
||||
raise StaffPinNotFoundException(str(pin_id))
|
||||
pin_service.delete_pin(db, pin_id)
|
||||
|
||||
|
||||
@@ -265,6 +273,10 @@ def unlock_pin(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Unlock a locked staff PIN."""
|
||||
pin = pin_service.require_pin(db, pin_id)
|
||||
if pin.store_id != current_user.token_store_id:
|
||||
from app.modules.loyalty.exceptions import StaffPinNotFoundException
|
||||
raise StaffPinNotFoundException(str(pin_id))
|
||||
pin = pin_service.unlock_pin(db, pin_id)
|
||||
return PinResponse.model_validate(pin)
|
||||
|
||||
@@ -762,7 +774,10 @@ def adjust_points(
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Manually adjust points (store operation)."""
|
||||
"""Manually adjust points (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can adjust points")
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.modules.loyalty.schemas import (
|
||||
CardResponse,
|
||||
ProgramResponse,
|
||||
)
|
||||
from app.modules.loyalty.schemas.program import StorefrontProgramResponse
|
||||
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
from middleware.decorators import rate_limit
|
||||
@@ -55,7 +56,7 @@ def get_program_info(
|
||||
if not program:
|
||||
return None
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response = StorefrontProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
@@ -206,6 +206,8 @@ class PointsAdjustRequest(BaseModel):
|
||||
|
||||
points_delta: int = Field(
|
||||
...,
|
||||
ge=-100000,
|
||||
le=100000,
|
||||
description="Points to add (positive) or remove (negative)",
|
||||
)
|
||||
reason: str = Field(
|
||||
|
||||
@@ -227,6 +227,16 @@ class ProgramResponse(BaseModel):
|
||||
total_points_redeemed: int | None = None
|
||||
|
||||
|
||||
class StorefrontProgramResponse(ProgramResponse):
|
||||
"""Program response for unauthenticated storefront visitors — excludes wallet integration IDs."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
google_issuer_id: None = Field(None, exclude=True)
|
||||
google_class_id: None = Field(None, exclude=True)
|
||||
apple_pass_type_id: None = Field(None, exclude=True)
|
||||
|
||||
|
||||
class ProgramListResponse(BaseModel):
|
||||
"""Schema for listing loyalty programs (admin)."""
|
||||
|
||||
|
||||
@@ -579,6 +579,16 @@ class CardService:
|
||||
"""Reactivate a deactivated loyalty card."""
|
||||
card = self.require_card(db, card_id)
|
||||
card.is_active = True
|
||||
|
||||
# Create reactivation transaction for audit trail
|
||||
transaction = LoyaltyTransaction(
|
||||
merchant_id=card.merchant_id,
|
||||
card_id=card.id,
|
||||
transaction_type=TransactionType.CARD_REACTIVATED.value,
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
|
||||
@@ -293,19 +293,35 @@ class PinService:
|
||||
|
||||
# No match found - record failed attempt on the first unlocked PIN only
|
||||
# This limits blast radius to 1 lockout instead of N
|
||||
|
||||
# Use merchant-specific settings if available, fall back to global config
|
||||
max_attempts = config.pin_max_failed_attempts
|
||||
lockout_minutes = config.pin_lockout_minutes
|
||||
if pins:
|
||||
from app.modules.loyalty.models import LoyaltyProgram
|
||||
program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first()
|
||||
if program:
|
||||
from app.modules.loyalty.services.program_service import (
|
||||
program_service as _ps,
|
||||
)
|
||||
merchant_settings = _ps.get_merchant_settings(db, program.merchant_id)
|
||||
if merchant_settings:
|
||||
max_attempts = merchant_settings.staff_pin_lockout_attempts
|
||||
lockout_minutes = merchant_settings.staff_pin_lockout_minutes
|
||||
|
||||
locked_pin = None
|
||||
remaining = None
|
||||
|
||||
for pin in pins:
|
||||
if not pin.is_locked:
|
||||
is_now_locked = pin.record_failed_attempt(
|
||||
max_attempts=config.pin_max_failed_attempts,
|
||||
lockout_minutes=config.pin_lockout_minutes,
|
||||
max_attempts=max_attempts,
|
||||
lockout_minutes=lockout_minutes,
|
||||
)
|
||||
if is_now_locked:
|
||||
locked_pin = pin
|
||||
else:
|
||||
remaining = max(0, config.pin_max_failed_attempts - pin.failed_attempts)
|
||||
remaining = max(0, max_attempts - pin.failed_attempts)
|
||||
break # Only record on the first unlocked PIN
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -101,6 +101,31 @@ class PointsService:
|
||||
if settings and settings.require_order_reference and not order_reference:
|
||||
raise OrderReferenceRequiredException()
|
||||
|
||||
# Idempotency guard: if same order_reference already earned points on this card, return existing result
|
||||
if order_reference:
|
||||
existing_tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.order_reference == order_reference,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_tx:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Points already earned for this order",
|
||||
"points_earned": existing_tx.points_delta,
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"purchase_amount_cents": existing_tx.purchase_amount_cents or purchase_amount_cents,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
"store_id": existing_tx.store_id,
|
||||
}
|
||||
|
||||
# Check minimum purchase amount
|
||||
if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents:
|
||||
return {
|
||||
@@ -263,10 +288,6 @@ class PointsService:
|
||||
if points_required < program.minimum_redemption_points:
|
||||
raise InvalidRewardException(reward_id)
|
||||
|
||||
# Check if enough points
|
||||
if card.points_balance < points_required:
|
||||
raise InsufficientPointsException(card.points_balance, points_required)
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
@@ -277,6 +298,10 @@ class PointsService:
|
||||
# Re-fetch with row lock to prevent concurrent modification
|
||||
card = card_service.get_card_for_update(db, card.id)
|
||||
|
||||
# Check balance AFTER acquiring lock to prevent TOCTOU race
|
||||
if card.points_balance < points_required:
|
||||
raise InsufficientPointsException(card.points_balance, points_required)
|
||||
|
||||
# Redeem points
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance -= points_required
|
||||
@@ -432,6 +457,7 @@ class PointsService:
|
||||
now = datetime.now(UTC)
|
||||
actual_voided = min(points_to_void, card.points_balance)
|
||||
card.points_balance = max(0, card.points_balance - points_to_void)
|
||||
card.total_points_voided += actual_voided
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create void transaction
|
||||
|
||||
@@ -498,11 +498,8 @@ class ProgramService:
|
||||
db.add(program)
|
||||
db.flush()
|
||||
|
||||
# Create default merchant settings
|
||||
settings = MerchantLoyaltySettings(
|
||||
merchant_id=merchant_id,
|
||||
)
|
||||
db.add(settings)
|
||||
# Create default merchant settings (idempotent — skips if already exists)
|
||||
self.get_or_create_merchant_settings(db, merchant_id)
|
||||
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
@@ -110,7 +110,10 @@ class StampService:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id)
|
||||
|
||||
# Check cooldown
|
||||
# Re-fetch with row lock to prevent concurrent modification
|
||||
card = card_service.get_card_for_update(db, card.id)
|
||||
|
||||
# Check cooldown AFTER acquiring lock to prevent TOCTOU race
|
||||
now = datetime.now(UTC)
|
||||
if card.last_stamp_at:
|
||||
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
|
||||
@@ -120,14 +123,11 @@ class StampService:
|
||||
program.cooldown_minutes,
|
||||
)
|
||||
|
||||
# Check daily limit
|
||||
# Check daily limit AFTER acquiring lock
|
||||
stamps_today = card_service.get_stamps_today(db, card.id)
|
||||
if stamps_today >= program.max_daily_stamps:
|
||||
raise DailyStampLimitException(program.max_daily_stamps, stamps_today)
|
||||
|
||||
# Re-fetch with row lock to prevent concurrent modification
|
||||
card = card_service.get_card_for_update(db, card.id)
|
||||
|
||||
# Add the stamp
|
||||
card.stamp_count += 1
|
||||
card.total_stamps_earned += 1
|
||||
@@ -241,10 +241,6 @@ class StampService:
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if enough stamps
|
||||
if card.stamp_count < program.stamps_target:
|
||||
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
@@ -255,6 +251,10 @@ class StampService:
|
||||
# Re-fetch with row lock to prevent concurrent modification
|
||||
card = card_service.get_card_for_update(db, card.id)
|
||||
|
||||
# Check stamp count AFTER acquiring lock to prevent TOCTOU race
|
||||
if card.stamp_count < program.stamps_target:
|
||||
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
|
||||
|
||||
# Redeem stamps
|
||||
now = datetime.now(UTC)
|
||||
stamps_redeemed = program.stamps_target
|
||||
|
||||
@@ -462,3 +462,343 @@ class TestStampEarnRedeem:
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["stamp_count"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 2: PIN Ownership Checks
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pin_ownership_setup(db, loyalty_platform):
|
||||
"""Setup two stores under same merchant for PIN ownership tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.loyalty.models import StaffPin
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"pinown_{uid}@test.com",
|
||||
username=f"pinown_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Pin Own Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
# Store A (owner's store)
|
||||
store_a = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PINA_{uid.upper()}",
|
||||
subdomain=f"pina{uid}",
|
||||
name=f"Pin Store A {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store_a)
|
||||
db.commit()
|
||||
db.refresh(store_a)
|
||||
|
||||
store_user = StoreUser(store_id=store_a.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
sp = StorePlatform(store_id=store_a.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
# Store B (different store)
|
||||
store_b = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PINB_{uid.upper()}",
|
||||
subdomain=f"pinb{uid}",
|
||||
name=f"Pin Store B {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store_b)
|
||||
db.commit()
|
||||
db.refresh(store_b)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=True,
|
||||
card_name="Pin Own Card",
|
||||
card_color="#00FF00",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
# Create PIN on store B
|
||||
pin_b = StaffPin(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
store_id=store_b.id,
|
||||
name="Other Store Staff",
|
||||
)
|
||||
pin_b.set_pin("5678")
|
||||
db.add(pin_b)
|
||||
db.commit()
|
||||
db.refresh(pin_b)
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"merchant": merchant,
|
||||
"store_a": store_a,
|
||||
"store_b": store_b,
|
||||
"program": program,
|
||||
"pin_b": pin_b,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pin_ownership_headers(client, pin_ownership_setup):
|
||||
"""JWT auth headers for pin ownership setup (logged into store A)."""
|
||||
owner = pin_ownership_setup["owner"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": owner.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestPinOwnershipCheck:
|
||||
"""Tests for PIN ownership verification (Item 2)."""
|
||||
|
||||
def test_update_pin_wrong_store_returns_404(
|
||||
self, client, pin_ownership_headers, pin_ownership_setup
|
||||
):
|
||||
"""Updating a PIN belonging to another store returns 404."""
|
||||
pin_b = pin_ownership_setup["pin_b"]
|
||||
response = client.patch(
|
||||
f"{BASE}/pins/{pin_b.id}",
|
||||
json={"name": "Hacked Name"},
|
||||
headers=pin_ownership_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_pin_wrong_store_returns_404(
|
||||
self, client, pin_ownership_headers, pin_ownership_setup
|
||||
):
|
||||
"""Deleting a PIN belonging to another store returns 404."""
|
||||
pin_b = pin_ownership_setup["pin_b"]
|
||||
response = client.delete(
|
||||
f"{BASE}/pins/{pin_b.id}",
|
||||
headers=pin_ownership_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_unlock_pin_wrong_store_returns_404(
|
||||
self, client, pin_ownership_headers, pin_ownership_setup
|
||||
):
|
||||
"""Unlocking a PIN belonging to another store returns 404."""
|
||||
pin_b = pin_ownership_setup["pin_b"]
|
||||
response = client.post(
|
||||
f"{BASE}/pins/{pin_b.id}/unlock",
|
||||
headers=pin_ownership_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 3: adjust_points role gate
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_user_setup(db, loyalty_platform):
|
||||
"""Setup with a staff user (not merchant_owner) for role gate tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
# merchant_owner creates the merchant
|
||||
owner = User(
|
||||
email=f"adjowner_{uid}@test.com",
|
||||
username=f"adjowner_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Adjust Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"ADJ_{uid.upper()}",
|
||||
subdomain=f"adj{uid}",
|
||||
name=f"Adjust Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
# Staff user (not merchant_owner)
|
||||
staff = User(
|
||||
email=f"adjstaff_{uid}@test.com",
|
||||
username=f"adjstaff_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(staff)
|
||||
db.commit()
|
||||
db.refresh(staff)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=staff.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
sp = StorePlatform(store_id=store.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"adjcust_{uid}@test.com",
|
||||
first_name="Adj",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"AC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Adjust Card",
|
||||
card_color="#FF0000",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"ADJCARD-{uid.upper()}",
|
||||
points_balance=500,
|
||||
total_points_earned=500,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"staff": staff,
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_headers(client, staff_user_setup):
|
||||
"""JWT auth headers for staff user (not merchant_owner)."""
|
||||
staff = staff_user_setup["staff"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": staff.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestAdjustPointsRoleGate:
|
||||
"""Tests for adjust_points merchant_owner role requirement (Item 3)."""
|
||||
|
||||
def test_staff_cannot_adjust_points(
|
||||
self, client, staff_headers, staff_user_setup
|
||||
):
|
||||
"""Staff (non-owner) gets 403 when adjusting points."""
|
||||
card = staff_user_setup["card"]
|
||||
response = client.post(
|
||||
f"{BASE}/cards/{card.id}/points/adjust",
|
||||
json={"points_delta": 100, "reason": "Staff trying to adjust"},
|
||||
headers=staff_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_owner_can_adjust_points(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""Merchant owner can adjust points."""
|
||||
card = loyalty_store_setup["card"]
|
||||
response = client.post(
|
||||
f"{BASE}/cards/{card.id}/points/adjust",
|
||||
json={"points_delta": 50, "reason": "Owner adjustment test"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
@@ -290,3 +290,44 @@ class TestGetStoreTransactions:
|
||||
)
|
||||
assert total >= 3
|
||||
assert len(transactions) == 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 9: Reactivate card creates audit transaction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestReactivateCardAudit:
|
||||
"""Tests for reactivate_card creating CARD_REACTIVATED transaction."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CardService()
|
||||
|
||||
def test_reactivate_creates_transaction(self, db, test_loyalty_card):
|
||||
"""Reactivating a card creates a CARD_REACTIVATED transaction."""
|
||||
# Deactivate first
|
||||
test_loyalty_card.is_active = False
|
||||
db.commit()
|
||||
|
||||
self.service.reactivate_card(db, test_loyalty_card.id)
|
||||
|
||||
tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == test_loyalty_card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.CARD_REACTIVATED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert tx is not None
|
||||
assert tx.merchant_id == test_loyalty_card.merchant_id
|
||||
|
||||
def test_reactivate_sets_card_active(self, db, test_loyalty_card):
|
||||
"""Reactivating a card sets is_active to True."""
|
||||
test_loyalty_card.is_active = False
|
||||
db.commit()
|
||||
|
||||
card = self.service.reactivate_card(db, test_loyalty_card.id)
|
||||
assert card.is_active is True
|
||||
|
||||
@@ -233,3 +233,52 @@ class TestVerifyPin:
|
||||
# Should find the active PIN
|
||||
result = self.service.verify_pin(db, program.id, "2222", store_id=store.id)
|
||||
assert result.name == "Active"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 11: Merchant-specific PIN lockout settings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVerifyPinMerchantSettings:
|
||||
"""Tests for verify_pin using merchant-specific lockout settings."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PinService()
|
||||
|
||||
def test_uses_merchant_lockout_attempts(self, db, pin_setup):
|
||||
"""Failed attempts use merchant settings, not global config."""
|
||||
from app.modules.loyalty.models import MerchantLoyaltySettings
|
||||
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
merchant = pin_setup["merchant"]
|
||||
|
||||
# Create merchant settings with low lockout threshold
|
||||
settings = MerchantLoyaltySettings(
|
||||
merchant_id=merchant.id,
|
||||
staff_pin_lockout_attempts=3,
|
||||
staff_pin_lockout_minutes=60,
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="Test", pin="1234"))
|
||||
|
||||
# Fail 3 times (merchant setting), should lock
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
except (InvalidStaffPinException, StaffPinLockedException):
|
||||
pass
|
||||
|
||||
# Next attempt should be locked
|
||||
with pytest.raises((InvalidStaffPinException, StaffPinLockedException)):
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
|
||||
# Verify the PIN is actually locked
|
||||
pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True)
|
||||
locked_pins = [p for p in pins if p.is_locked]
|
||||
assert len(locked_pins) >= 1
|
||||
|
||||
@@ -372,3 +372,169 @@ class TestAdjustPoints:
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 1: TOCTOU Race Condition (redeem_points checks after lock)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRedeemPointsTOCTOU:
|
||||
"""Verify balance check happens after row lock."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_redeem_insufficient_after_lock(self, db, points_setup):
|
||||
"""Balance check uses locked row state, not stale read."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Set balance to exactly the reward cost
|
||||
card.points_balance = 100
|
||||
db.commit()
|
||||
|
||||
# Should succeed — balance is exactly enough
|
||||
result = self.service.redeem_points(
|
||||
db, store_id=store.id, card_id=card.id, reward_id="r1",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 0
|
||||
|
||||
# Second redemption should fail (balance is now 0 after lock)
|
||||
with pytest.raises(InsufficientPointsException):
|
||||
self.service.redeem_points(
|
||||
db, store_id=store.id, card_id=card.id, reward_id="r1",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 4: void_points updates total_points_voided
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVoidPointsTracking:
|
||||
"""Verify void_points increments total_points_voided."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_void_updates_total_points_voided(self, db, points_setup):
|
||||
"""total_points_voided is incremented on void."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
initial_voided = card.total_points_voided
|
||||
|
||||
self.service.void_points(
|
||||
db, store_id=store.id, card_id=card.id, points_to_void=50,
|
||||
)
|
||||
|
||||
db.refresh(card)
|
||||
assert card.total_points_voided == initial_voided + 50
|
||||
|
||||
def test_void_caps_voided_at_balance(self, db, points_setup):
|
||||
"""total_points_voided only counts actually voided amount (capped at balance)."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Set balance to 30, try to void 100
|
||||
card.points_balance = 30
|
||||
card.total_points_voided = 0
|
||||
db.commit()
|
||||
|
||||
self.service.void_points(
|
||||
db, store_id=store.id, card_id=card.id, points_to_void=100,
|
||||
)
|
||||
|
||||
db.refresh(card)
|
||||
assert card.total_points_voided == 30 # Only 30 was available
|
||||
assert card.points_balance == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 5: Duplicate order_reference guard
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestEarnPointsIdempotency:
|
||||
"""Verify duplicate order_reference returns existing result."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_duplicate_order_reference_returns_existing(self, db, points_setup):
|
||||
"""Same order_reference returns existing result without double-earning."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result1 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
order_reference="ORDER-DUP-001",
|
||||
)
|
||||
balance_after_first = result1["points_balance"]
|
||||
|
||||
result2 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
order_reference="ORDER-DUP-001",
|
||||
)
|
||||
|
||||
# Second call should return same points_earned, balance unchanged
|
||||
assert result2["points_earned"] == result1["points_earned"]
|
||||
assert result2["points_balance"] == balance_after_first
|
||||
assert result2["message"] == "Points already earned for this order"
|
||||
|
||||
def test_different_order_references_earn_separately(self, db, points_setup):
|
||||
"""Different order_references earn points independently."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result1 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
order_reference="ORDER-A",
|
||||
)
|
||||
result2 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
order_reference="ORDER-B",
|
||||
)
|
||||
|
||||
assert result2["points_balance"] > result1["points_balance"]
|
||||
assert result2["message"] == "Points earned successfully"
|
||||
|
||||
def test_no_order_reference_allows_multiple_earns(self, db, points_setup):
|
||||
"""Without order_reference, multiple earns are allowed."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result1 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
)
|
||||
result2 = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=1000,
|
||||
)
|
||||
|
||||
assert result2["points_balance"] > result1["points_balance"]
|
||||
|
||||
@@ -236,6 +236,38 @@ class TestCreateProgram:
|
||||
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||
assert settings is not None
|
||||
|
||||
def test_create_program_with_existing_settings_no_error(self, db, ps_merchant):
|
||||
"""Creating a program when MerchantLoyaltySettings already exists succeeds (Item 12)."""
|
||||
from app.modules.loyalty.models import MerchantLoyaltySettings
|
||||
|
||||
# Pre-create settings
|
||||
settings = MerchantLoyaltySettings(merchant_id=ps_merchant.id)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
|
||||
# Should not raise IntegrityError
|
||||
data = ProgramCreate(loyalty_type="points")
|
||||
program = self.service.create_program(db, ps_merchant.id, data)
|
||||
assert program is not None
|
||||
|
||||
# Should still have exactly one settings row
|
||||
count = (
|
||||
db.query(MerchantLoyaltySettings)
|
||||
.filter(MerchantLoyaltySettings.merchant_id == ps_merchant.id)
|
||||
.count()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
def test_duplicate_program_exception_uses_merchant_id(self, db, ps_program, ps_merchant):
|
||||
"""LoyaltyProgramAlreadyExistsException message references merchant, not store (Item 6)."""
|
||||
data = ProgramCreate(loyalty_type="points")
|
||||
with pytest.raises(LoyaltyProgramAlreadyExistsException) as exc_info:
|
||||
self.service.create_program(db, ps_merchant.id, data)
|
||||
|
||||
assert "Merchant" in str(exc_info.value.message)
|
||||
assert str(ps_merchant.id) in str(exc_info.value.message)
|
||||
assert exc_info.value.details["merchant_id"] == ps_merchant.id
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Update Operations
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.modules.loyalty.schemas.card import (
|
||||
CardEnrollRequest,
|
||||
TransactionResponse,
|
||||
)
|
||||
from app.modules.loyalty.schemas.points import PointsAdjustRequest
|
||||
from app.modules.loyalty.schemas.program import StorefrontProgramResponse
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -73,3 +76,104 @@ class TestTransactionResponse:
|
||||
created_at=now,
|
||||
)
|
||||
assert tx.customer_name == "John Doe"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 8: PointsAdjustRequest bounds
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestPointsAdjustRequestBounds:
|
||||
"""Tests for PointsAdjustRequest.points_delta bounds."""
|
||||
|
||||
def test_valid_positive_delta(self):
|
||||
"""Positive delta within bounds is valid."""
|
||||
req = PointsAdjustRequest(points_delta=100, reason="Bonus for customer")
|
||||
assert req.points_delta == 100
|
||||
|
||||
def test_valid_negative_delta(self):
|
||||
"""Negative delta within bounds is valid."""
|
||||
req = PointsAdjustRequest(points_delta=-500, reason="Correction needed")
|
||||
assert req.points_delta == -500
|
||||
|
||||
def test_max_boundary(self):
|
||||
"""Delta at max boundary (100000) is valid."""
|
||||
req = PointsAdjustRequest(points_delta=100000, reason="Max allowed delta")
|
||||
assert req.points_delta == 100000
|
||||
|
||||
def test_min_boundary(self):
|
||||
"""Delta at min boundary (-100000) is valid."""
|
||||
req = PointsAdjustRequest(points_delta=-100000, reason="Min allowed delta")
|
||||
assert req.points_delta == -100000
|
||||
|
||||
def test_exceeds_max_rejected(self):
|
||||
"""Delta exceeding 100000 is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
PointsAdjustRequest(points_delta=100001, reason="Too many points")
|
||||
|
||||
def test_exceeds_min_rejected(self):
|
||||
"""Delta below -100000 is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
PointsAdjustRequest(points_delta=-100001, reason="Too many negative")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 7: StorefrontProgramResponse excludes wallet fields
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestStorefrontProgramResponse:
|
||||
"""Tests for StorefrontProgramResponse excluding wallet IDs."""
|
||||
|
||||
def test_wallet_fields_excluded_from_serialization(self):
|
||||
"""Wallet integration IDs are excluded from JSON output."""
|
||||
now = datetime.now(UTC)
|
||||
response = StorefrontProgramResponse(
|
||||
id=1,
|
||||
merchant_id=1,
|
||||
loyalty_type="points",
|
||||
stamps_target=10,
|
||||
stamps_reward_description="Free item",
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=15,
|
||||
max_daily_stamps=5,
|
||||
require_staff_pin=True,
|
||||
card_color="#4F46E5",
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
data = response.model_dump()
|
||||
assert "google_issuer_id" not in data
|
||||
assert "google_class_id" not in data
|
||||
assert "apple_pass_type_id" not in data
|
||||
|
||||
def test_non_wallet_fields_present(self):
|
||||
"""Non-wallet fields are still present."""
|
||||
now = datetime.now(UTC)
|
||||
response = StorefrontProgramResponse(
|
||||
id=1,
|
||||
merchant_id=1,
|
||||
loyalty_type="points",
|
||||
stamps_target=10,
|
||||
stamps_reward_description="Free item",
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=15,
|
||||
max_daily_stamps=5,
|
||||
require_staff_pin=True,
|
||||
card_color="#4F46E5",
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
data = response.model_dump()
|
||||
assert data["id"] == 1
|
||||
assert data["loyalty_type"] == "points"
|
||||
assert data["points_per_euro"] == 10
|
||||
assert data["card_color"] == "#4F46E5"
|
||||
|
||||
@@ -285,3 +285,69 @@ class TestVoidStamps:
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["stamp_count"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 1: TOCTOU Race Condition (stamp checks after lock)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestStampTOCTOU:
|
||||
"""Verify cooldown/daily-limit checks happen after row lock."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_cooldown_checked_after_lock(self, db, stamp_setup):
|
||||
"""Cooldown check uses locked row state."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Enable cooldown
|
||||
program.cooldown_minutes = 60
|
||||
db.commit()
|
||||
|
||||
# First stamp succeeds
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Second stamp should fail (cooldown active after lock)
|
||||
with pytest.raises(StampCooldownException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
def test_daily_limit_checked_after_lock(self, db, stamp_setup):
|
||||
"""Daily limit check uses locked row state."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
program.max_daily_stamps = 1
|
||||
db.commit()
|
||||
|
||||
# First stamp succeeds
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Second stamp should fail (daily limit after lock)
|
||||
with pytest.raises(DailyStampLimitException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
def test_redeem_stamps_insufficient_after_lock(self, db, stamp_setup):
|
||||
"""Stamp count check uses locked row state."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Give exact stamp target
|
||||
card.stamp_count = program.stamps_target
|
||||
card.total_stamps_earned = program.stamps_target
|
||||
db.commit()
|
||||
|
||||
# First redeem succeeds
|
||||
result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
assert result["success"] is True
|
||||
|
||||
# Second redeem should fail (0 stamps after lock)
|
||||
with pytest.raises(InsufficientStampsException):
|
||||
self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
Reference in New Issue
Block a user