php-commerce/tests/Feature/CouponServiceTest.php

362 lines
12 KiB
PHP
Raw Normal View History

2026-01-27 00:24:22 +00:00
<?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;
2026-01-27 00:24:22 +00:00
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');
});
});
});