php-commerce/tests/Feature/CouponServiceTest.php
Snider 8f27fe85c3 refactor: update Tenant module imports after namespace migration
Updates all references from Core\Mod\Tenant to Core\Tenant following
the monorepo separation. The Tenant module now lives in its own package
with the simplified namespace.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:39: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\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('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');
});
});
});