php-commerce/tests/Feature/CouponServiceTest.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

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

361 lines
12 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\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\User;
use Core\Mod\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('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();
});
});
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('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');
});
});
});