php-commerce/tests/Feature/CouponServiceTest.php
Snider 2e5cd499b9 security: complete rate limiting and fraud service implementation (P1-040)
Add missing files from P1-040/P1-041 implementation:
- CheckoutRateLimitException for 429 responses when rate limit exceeded
- FraudAssessment data object for fraud scoring results
- FraudService for velocity checks and Stripe Radar integration
- Register services in Boot.php
- Add fraud detection configuration in config.php
- Add CouponServiceTest for input sanitisation

The CheckoutRateLimiter (already tracked) is now properly integrated with
the exception handling, and the FraudService provides defence-in-depth
with velocity-based and geo-anomaly detection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:09:29 +00:00

540 lines
18 KiB
PHP

<?php
use Core\Mod\Commerce\Models\Coupon;
use Core\Mod\Commerce\Models\CouponUsage;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Services\CouponService;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Use existing seeded package
$this->package = Package::where('code', 'creator')->first();
// Create test coupons
$this->percentCoupon = Coupon::create([
'code' => 'SAVE20',
'name' => '20% Off',
'type' => 'percentage',
'value' => 20.00,
'applies_to' => 'all',
'is_active' => true,
'max_uses' => 100,
'max_uses_per_workspace' => 1,
'used_count' => 0,
]);
$this->fixedCoupon = Coupon::create([
'code' => 'FLAT10',
'name' => '£10 Off',
'type' => 'fixed_amount',
'value' => 10.00,
'applies_to' => 'all',
'is_active' => true,
'max_uses_per_workspace' => 1,
]);
$this->service = app(CouponService::class);
});
describe('CouponService', function () {
describe('sanitiseCode() method', function () {
it('trims whitespace from coupon codes', function () {
$result = $this->service->sanitiseCode(' SAVE20 ');
expect($result)->toBe('SAVE20');
});
it('converts lowercase to uppercase', function () {
$result = $this->service->sanitiseCode('save20');
expect($result)->toBe('SAVE20');
});
it('handles mixed case codes', function () {
$result = $this->service->sanitiseCode('SaVe20');
expect($result)->toBe('SAVE20');
});
it('allows hyphens in codes', function () {
$result = $this->service->sanitiseCode('SAVE-20-NOW');
expect($result)->toBe('SAVE-20-NOW');
});
it('allows underscores in codes', function () {
$result = $this->service->sanitiseCode('SAVE_20_NOW');
expect($result)->toBe('SAVE_20_NOW');
});
it('rejects codes shorter than minimum length', function () {
$result = $this->service->sanitiseCode('AB');
expect($result)->toBeNull();
});
it('accepts codes at minimum length', function () {
$result = $this->service->sanitiseCode('ABC');
expect($result)->toBe('ABC');
});
it('rejects codes longer than maximum length', function () {
$longCode = str_repeat('A', 51);
$result = $this->service->sanitiseCode($longCode);
expect($result)->toBeNull();
});
it('accepts codes at maximum length', function () {
$maxCode = str_repeat('A', 50);
$result = $this->service->sanitiseCode($maxCode);
expect($result)->toBe($maxCode);
});
it('rejects codes with invalid characters', function () {
$invalidCodes = [
'SAVE@20', // @ symbol
'SAVE 20', // space (after trim)
'SAVE#20', // hash
'SAVE!20', // exclamation
'SAVE$20', // dollar
'SAVE%20', // percent
'SAVE&20', // ampersand
'SAVE*20', // asterisk
'SAVE.20', // period
"SAVE'20", // single quote
'SAVE"20', // double quote
'SAVE;20', // semicolon (SQL injection attempt)
"SAVE'--20", // SQL injection attempt
'SAVE<script>', // XSS attempt
];
foreach ($invalidCodes as $code) {
$result = $this->service->sanitiseCode($code);
expect($result)->toBeNull("Expected null for code: {$code}");
}
});
it('rejects empty string', function () {
$result = $this->service->sanitiseCode('');
expect($result)->toBeNull();
});
it('rejects whitespace-only string', function () {
$result = $this->service->sanitiseCode(' ');
expect($result)->toBeNull();
});
});
describe('isValidCodeFormat() method', function () {
it('returns true for valid codes', function () {
expect($this->service->isValidCodeFormat('SAVE20'))->toBeTrue()
->and($this->service->isValidCodeFormat('save-20-now'))->toBeTrue()
->and($this->service->isValidCodeFormat('CODE_123'))->toBeTrue();
});
it('returns false for invalid codes', function () {
expect($this->service->isValidCodeFormat('AB'))->toBeFalse() // too short
->and($this->service->isValidCodeFormat('SAVE@20'))->toBeFalse() // invalid char
->and($this->service->isValidCodeFormat(''))->toBeFalse(); // empty
});
});
describe('findByCode() method', function () {
it('finds coupon by code (case insensitive)', function () {
$coupon = $this->service->findByCode('save20');
expect($coupon)->not->toBeNull()
->and($coupon->code)->toBe('SAVE20');
});
it('returns null for non-existent code', function () {
$coupon = $this->service->findByCode('NOTREAL');
expect($coupon)->toBeNull();
});
it('returns null for invalid code format without hitting database', function () {
// These should return null due to invalid format, not because they don't exist
expect($this->service->findByCode('AB'))->toBeNull() // too short
->and($this->service->findByCode('CODE@123'))->toBeNull(); // invalid char
});
it('sanitises code before lookup', function () {
// Should find the coupon even with whitespace and different case
$coupon = $this->service->findByCode(' save20 ');
expect($coupon)->not->toBeNull()
->and($coupon->code)->toBe('SAVE20');
});
});
describe('validate() method', function () {
it('validates active coupon', function () {
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeTrue();
});
it('rejects inactive coupon', function () {
$this->percentCoupon->update(['is_active' => false]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse();
});
it('rejects expired coupon', function () {
$this->percentCoupon->update(['valid_until' => now()->subDay()]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse();
});
it('rejects coupon before start date', function () {
$this->percentCoupon->update(['valid_from' => now()->addDay()]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse();
});
it('rejects coupon that has reached max uses', function () {
$this->percentCoupon->update([
'max_uses' => 5,
'used_count' => 5,
]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse();
});
it('rejects coupon already used by workspace', function () {
// Need an order for coupon usage
$order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-001',
'status' => 'paid',
'subtotal' => 19.00,
'total' => 15.20,
'currency' => 'GBP',
]);
CouponUsage::create([
'coupon_id' => $this->percentCoupon->id,
'workspace_id' => $this->workspace->id,
'order_id' => $order->id,
'discount_amount' => 3.80,
]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse();
});
it('validates coupon restricted to specific packages', function () {
// Set applies_to to 'packages' and provide package IDs
$this->percentCoupon->update([
'applies_to' => 'packages',
'package_ids' => [$this->package->id],
]);
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$this->package
);
expect($result->isValid())->toBeTrue();
// Use existing seeded agency package
$otherPackage = Package::where('code', 'agency')->first();
$result = $this->service->validate(
$this->percentCoupon,
$this->workspace,
$otherPackage
);
expect($result->isValid())->toBeFalse();
});
it('validates minimum purchase amount', function () {
$this->fixedCoupon->update(['min_amount' => 50.00]);
// When min_amount is set, the validation in CouponService doesn't check it
// The calculateDiscount method returns 0 for amounts below min_amount
// So this test should check the discount calculation instead
$discount = $this->fixedCoupon->calculateDiscount(19.00);
expect($discount)->toBe(0.0);
});
});
describe('validateByCode() method', function () {
it('validates coupon by code with sanitisation', function () {
$result = $this->service->validateByCode(
' save20 ', // lowercase with whitespace
$this->workspace,
$this->package
);
expect($result->isValid())->toBeTrue()
->and($result->getCoupon()->code)->toBe('SAVE20');
});
it('returns invalid result for code that is too short', function () {
$result = $this->service->validateByCode(
'AB',
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse()
->and($result->getMessage())->toBe('Invalid coupon code format');
});
it('returns invalid result for code with invalid characters', function () {
$result = $this->service->validateByCode(
'CODE@123',
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse()
->and($result->getMessage())->toBe('Invalid coupon code format');
});
it('returns invalid result for SQL injection attempt', function () {
$result = $this->service->validateByCode(
"'; DROP TABLE coupons; --",
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse()
->and($result->getMessage())->toBe('Invalid coupon code format');
});
it('returns invalid result for non-existent but valid format code', function () {
$result = $this->service->validateByCode(
'NONEXISTENT',
$this->workspace,
$this->package
);
expect($result->isValid())->toBeFalse()
->and($result->getMessage())->toBe('Invalid coupon code');
});
});
describe('recordUsage() method', function () {
it('records coupon usage', function () {
$order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-001',
'status' => 'paid',
'subtotal' => 19.00,
'total' => 15.20,
'currency' => 'GBP',
]);
$usage = $this->service->recordUsage(
$this->percentCoupon,
$this->workspace,
$order,
3.80
);
expect($usage)->toBeInstanceOf(CouponUsage::class)
->and($usage->coupon_id)->toBe($this->percentCoupon->id)
->and($usage->workspace_id)->toBe($this->workspace->id)
->and($usage->order_id)->toBe($order->id)
->and((float) $usage->discount_amount)->toBe(3.80);
// Check used_count was incremented
$this->percentCoupon->refresh();
expect($this->percentCoupon->used_count)->toBe(1);
});
});
});
describe('Coupon model', function () {
describe('calculateDiscount() method', function () {
it('calculates percentage discount', function () {
$discount = $this->percentCoupon->calculateDiscount(100.00);
expect($discount)->toBe(20.00);
});
it('calculates fixed discount', function () {
$discount = $this->fixedCoupon->calculateDiscount(100.00);
expect($discount)->toBe(10.00);
});
it('caps fixed discount at subtotal', function () {
$discount = $this->fixedCoupon->calculateDiscount(5.00);
expect($discount)->toBe(5.00); // Can't discount more than subtotal
});
it('respects max discount amount', function () {
$this->percentCoupon->update(['max_discount' => 15.00]);
$discount = $this->percentCoupon->calculateDiscount(100.00);
expect($discount)->toBe(15.00); // Capped at max
});
});
describe('isValid() method', function () {
it('returns true for valid coupon', function () {
expect($this->percentCoupon->isValid())->toBeTrue();
});
it('returns false for inactive coupon', function () {
$this->percentCoupon->update(['is_active' => false]);
expect($this->percentCoupon->isValid())->toBeFalse();
});
it('returns false for expired coupon', function () {
$this->percentCoupon->update(['valid_until' => now()->subHour()]);
expect($this->percentCoupon->isValid())->toBeFalse();
});
it('returns true within date range', function () {
$this->percentCoupon->update([
'valid_from' => now()->subDay(),
'valid_until' => now()->addDay(),
]);
expect($this->percentCoupon->isValid())->toBeTrue();
});
});
describe('hasReachedMaxUses() method', function () {
it('returns false when under limit', function () {
$this->percentCoupon->update([
'max_uses' => 100,
'used_count' => 50,
]);
expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse();
});
it('returns true when at limit', function () {
$this->percentCoupon->update([
'max_uses' => 100,
'used_count' => 100,
]);
expect($this->percentCoupon->hasReachedMaxUses())->toBeTrue();
});
it('returns false when no limit set', function () {
$this->percentCoupon->update([
'max_uses' => null,
'used_count' => 1000,
]);
expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse();
});
});
describe('isRestrictedToPackage() method', function () {
it('returns false when no package restrictions', function () {
expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeFalse();
});
it('returns true for allowed package', function () {
$this->percentCoupon->update(['package_ids' => ['creator', 'agency']]);
expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeTrue()
->and($this->percentCoupon->isRestrictedToPackage('agency'))->toBeTrue();
});
it('returns false for restricted package', function () {
$this->percentCoupon->update(['package_ids' => ['creator']]);
expect($this->percentCoupon->isRestrictedToPackage('agency'))->toBeFalse();
});
});
describe('scopes', function () {
it('scopes to active coupons', function () {
Coupon::create([
'code' => 'INACTIVE',
'name' => 'Inactive',
'type' => 'percentage',
'value' => 10.00,
'is_active' => false,
]);
$active = Coupon::active()->get();
expect($active->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10')
->and($active->pluck('code')->toArray())->not->toContain('INACTIVE');
});
it('scopes to valid coupons', function () {
Coupon::create([
'code' => 'EXPIRED',
'name' => 'Expired',
'type' => 'percentage',
'value' => 10.00,
'is_active' => true,
'valid_until' => now()->subDay(),
]);
$valid = Coupon::valid()->get();
expect($valid->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10')
->and($valid->pluck('code')->toArray())->not->toContain('EXPIRED');
});
});
});