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>
285 lines
8.9 KiB
Python
285 lines
8.9 KiB
Python
"""Unit tests for PinService."""
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from app.modules.loyalty.exceptions import (
|
|
InvalidStaffPinException,
|
|
StaffPinLockedException,
|
|
)
|
|
from app.modules.loyalty.models import LoyaltyProgram, StaffPin
|
|
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
|
from app.modules.loyalty.schemas.pin import PinCreate
|
|
from app.modules.loyalty.services.pin_service import PinService
|
|
from app.modules.tenancy.models import Merchant, Store, User
|
|
from app.modules.tenancy.models.store import StoreUser
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.loyalty
|
|
class TestPinService:
|
|
"""Test suite for PinService."""
|
|
|
|
def setup_method(self):
|
|
self.service = PinService()
|
|
|
|
def test_service_instantiation(self):
|
|
"""Service can be instantiated."""
|
|
assert self.service is not None
|
|
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def pin_setup(db):
|
|
"""Create a full setup for PIN tests."""
|
|
from middleware.auth import AuthManager
|
|
|
|
auth = AuthManager()
|
|
uid = uuid.uuid4().hex[:8]
|
|
|
|
owner = User(
|
|
email=f"pinowner_{uid}@test.com",
|
|
username=f"pinowner_{uid}",
|
|
hashed_password=auth.hash_password("testpass"),
|
|
role="merchant_owner",
|
|
is_active=True,
|
|
is_email_verified=True,
|
|
)
|
|
db.add(owner)
|
|
db.commit()
|
|
db.refresh(owner)
|
|
|
|
merchant = Merchant(
|
|
name=f"PIN 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"PIN_{uid.upper()}",
|
|
subdomain=f"pin{uid}",
|
|
name=f"PIN Store {uid}",
|
|
is_active=True,
|
|
is_verified=True,
|
|
)
|
|
db.add(store)
|
|
db.commit()
|
|
db.refresh(store)
|
|
|
|
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
|
db.add(store_user)
|
|
db.commit()
|
|
|
|
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 Card",
|
|
card_color="#00FF00",
|
|
is_active=True,
|
|
)
|
|
db.add(program)
|
|
db.commit()
|
|
db.refresh(program)
|
|
|
|
return {
|
|
"merchant": merchant,
|
|
"store": store,
|
|
"program": program,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Create / Unlock Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.loyalty
|
|
class TestCreatePin:
|
|
"""Tests for create_pin."""
|
|
|
|
def setup_method(self):
|
|
self.service = PinService()
|
|
|
|
def test_create_pin(self, db, pin_setup):
|
|
"""Create a staff PIN."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
data = PinCreate(name="Alice", staff_id="EMP001", pin="1234")
|
|
pin = self.service.create_pin(db, program.id, store.id, data)
|
|
|
|
assert pin.id is not None
|
|
assert pin.name == "Alice"
|
|
assert pin.staff_id == "EMP001"
|
|
assert pin.verify_pin("1234")
|
|
|
|
def test_unlock_pin(self, db, pin_setup):
|
|
"""Unlock a locked PIN."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
data = PinCreate(name="Bob", staff_id="EMP002", pin="5678")
|
|
pin = self.service.create_pin(db, program.id, store.id, data)
|
|
|
|
# Lock it
|
|
pin.failed_attempts = 5
|
|
from datetime import timedelta
|
|
pin.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
|
db.commit()
|
|
|
|
assert pin.is_locked
|
|
|
|
# Unlock
|
|
unlocked = self.service.unlock_pin(db, pin.id)
|
|
assert unlocked.failed_attempts == 0
|
|
assert unlocked.locked_until is None
|
|
assert not unlocked.is_locked
|
|
|
|
|
|
# ============================================================================
|
|
# Verify PIN Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.loyalty
|
|
class TestVerifyPin:
|
|
"""Tests for verify_pin."""
|
|
|
|
def setup_method(self):
|
|
self.service = PinService()
|
|
|
|
def test_verify_pin_success(self, db, pin_setup):
|
|
"""Correct PIN verifies successfully."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
data = PinCreate(name="Charlie", staff_id="EMP003", pin="1111")
|
|
self.service.create_pin(db, program.id, store.id, data)
|
|
|
|
result = self.service.verify_pin(db, program.id, "1111", store_id=store.id)
|
|
assert result.name == "Charlie"
|
|
|
|
def test_verify_pin_wrong_single_failure(self, db, pin_setup):
|
|
"""Wrong PIN records failure on ONE pin only, not all."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
# Create two PINs
|
|
self.service.create_pin(db, program.id, store.id, PinCreate(name="A", pin="1111"))
|
|
self.service.create_pin(db, program.id, store.id, PinCreate(name="B", pin="2222"))
|
|
|
|
# Wrong PIN
|
|
with pytest.raises(InvalidStaffPinException):
|
|
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
|
|
|
# Only one PIN should have failed_attempts incremented
|
|
pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True)
|
|
failed_counts = [p.failed_attempts for p in pins]
|
|
assert sum(failed_counts) == 1 # Only 1 PIN got the failure, not both
|
|
|
|
def test_verify_pin_lockout(self, db, pin_setup):
|
|
"""After max failures, PIN gets locked."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
self.service.create_pin(db, program.id, store.id, PinCreate(name="Lock", pin="3333"))
|
|
|
|
# Fail 5 times (default max)
|
|
for _ in range(5):
|
|
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)
|
|
|
|
def test_verify_skips_locked_pins(self, db, pin_setup):
|
|
"""Locked PINs are skipped during verification."""
|
|
program = pin_setup["program"]
|
|
store = pin_setup["store"]
|
|
|
|
from datetime import timedelta
|
|
|
|
# Create a locked PIN and an unlocked one
|
|
data1 = PinCreate(name="Locked", pin="1111")
|
|
pin1 = self.service.create_pin(db, program.id, store.id, data1)
|
|
pin1.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
|
pin1.failed_attempts = 5
|
|
db.commit()
|
|
|
|
data2 = PinCreate(name="Active", pin="2222")
|
|
self.service.create_pin(db, program.id, store.id, data2)
|
|
|
|
# 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
|