Includes all work from phases 6-9+ and frontend polish rounds 1 & 2: - Login history with device trust, new device notifications, session management - Churn prevention: cancellation surveys, winback campaigns with email sequences - Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export - Configurable checkout: plan config groups/options, build-your-own VPS - Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap, Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer, remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%, GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy - WHMCS migration scripts - Update legal page effective dates to March 16, 2026 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
11 KiB
PHP
336 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Coupon;
|
|
use App\Models\CouponRedemption;
|
|
use App\Models\User;
|
|
use Database\Seeders\RoleAndPermissionSeeder;
|
|
// DatabaseTransactions is applied globally in Pest.php
|
|
use Laravel\Cashier\Subscription;
|
|
|
|
// uses DatabaseTransactions from Pest.php
|
|
|
|
beforeEach(function (): void {
|
|
$this->seed(RoleAndPermissionSeeder::class);
|
|
$this->admin = User::factory()->admin()->create();
|
|
|
|
$this->actingAs($this->admin);
|
|
});
|
|
|
|
test('admin can view coupon redemptions page', function (): void {
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->has('redemptions')
|
|
->has('coupons')
|
|
->has('stats')
|
|
->has('filters')
|
|
);
|
|
});
|
|
|
|
test('redemptions page displays all redemptions', function (): void {
|
|
$coupon = Coupon::factory()->create(['code' => 'TEST50']);
|
|
$user = User::factory()->create();
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['discount_amount' => '10.00']);
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['discount_amount' => '15.00']);
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 2)
|
|
->where('stats.total_redemptions', 2)
|
|
->where('stats.total_discount', 25)
|
|
);
|
|
});
|
|
|
|
test('redemptions can be filtered by coupon', function (): void {
|
|
$coupon1 = Coupon::factory()->create(['code' => 'COUPON1']);
|
|
$coupon2 = Coupon::factory()->create(['code' => 'COUPON2']);
|
|
$user = User::factory()->create();
|
|
|
|
CouponRedemption::factory()->for($coupon1)->for($user)->create();
|
|
CouponRedemption::factory()->for($coupon2)->for($user)->create();
|
|
CouponRedemption::factory()->for($coupon2)->for($user)->create();
|
|
|
|
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?coupon_id={$coupon2->id}");
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 2)
|
|
->where('filters.coupon_id', (string) $coupon2->id)
|
|
);
|
|
});
|
|
|
|
test('redemptions can be filtered by customer name', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user1 = User::factory()->create(['name' => 'John Doe']);
|
|
$user2 = User::factory()->create(['name' => 'Jane Smith']);
|
|
|
|
CouponRedemption::factory()->for($coupon)->for($user1)->create();
|
|
CouponRedemption::factory()->for($coupon)->for($user2)->create();
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?customer=John');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 1)
|
|
->where('filters.customer', 'John')
|
|
);
|
|
});
|
|
|
|
test('redemptions can be filtered by customer email', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user1 = User::factory()->create(['email' => 'john@example.com']);
|
|
$user2 = User::factory()->create(['email' => 'jane@example.com']);
|
|
|
|
CouponRedemption::factory()->for($coupon)->for($user1)->create();
|
|
CouponRedemption::factory()->for($coupon)->for($user2)->create();
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?customer=john@example.com');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 1)
|
|
);
|
|
});
|
|
|
|
test('redemptions can be filtered by date range', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
// Create redemptions on different dates
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['created_at' => now()->subDays(10)]);
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['created_at' => now()->subDays(5)]);
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['created_at' => now()->subDay()]);
|
|
|
|
$dateFrom = now()->subDays(6)->format('Y-m-d');
|
|
$dateTo = now()->format('Y-m-d');
|
|
|
|
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?date_from={$dateFrom}&date_to={$dateTo}");
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 2)
|
|
);
|
|
});
|
|
|
|
test('redemptions page calculates correct stats', function (): void {
|
|
$coupon1 = Coupon::factory()->create();
|
|
$coupon2 = Coupon::factory()->create();
|
|
$user1 = User::factory()->create();
|
|
$user2 = User::factory()->create();
|
|
$user3 = User::factory()->create();
|
|
|
|
// Create multiple redemptions
|
|
CouponRedemption::factory()->for($coupon1)->for($user1)->create(['discount_amount' => '10.00']);
|
|
CouponRedemption::factory()->for($coupon1)->for($user2)->create(['discount_amount' => '15.00']);
|
|
CouponRedemption::factory()->for($coupon2)->for($user3)->create(['discount_amount' => '20.00']);
|
|
CouponRedemption::factory()->for($coupon2)->for($user1)->create(['discount_amount' => '25.00']);
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('stats.total_redemptions', 4)
|
|
->where('stats.total_discount', 70)
|
|
->where('stats.unique_customers', 3)
|
|
->where('stats.unique_coupons', 2)
|
|
);
|
|
});
|
|
|
|
test('stats are filtered correctly when filters are applied', function (): void {
|
|
$coupon1 = Coupon::factory()->create();
|
|
$coupon2 = Coupon::factory()->create();
|
|
$user1 = User::factory()->create();
|
|
$user2 = User::factory()->create();
|
|
|
|
CouponRedemption::factory()->for($coupon1)->for($user1)->create(['discount_amount' => '10.00']);
|
|
CouponRedemption::factory()->for($coupon1)->for($user2)->create(['discount_amount' => '15.00']);
|
|
CouponRedemption::factory()->for($coupon2)->for($user1)->create(['discount_amount' => '20.00']);
|
|
|
|
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?coupon_id={$coupon1->id}");
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('stats.total_redemptions', 2)
|
|
->where('stats.total_discount', 25)
|
|
->where('stats.unique_customers', 2)
|
|
->where('stats.unique_coupons', 1)
|
|
);
|
|
});
|
|
|
|
test('redemptions page eager loads relationships', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user = User::factory()->create();
|
|
$subscription = Subscription::factory()
|
|
->for($user)
|
|
->create();
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->for($subscription)
|
|
->create();
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.data.0.coupon.code', $coupon->code)
|
|
->where('redemptions.data.0.user.name', $user->name)
|
|
->has('redemptions.data.0.subscription')
|
|
);
|
|
});
|
|
|
|
test('redemptions are paginated correctly', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
// Create 30 redemptions
|
|
CouponRedemption::factory()
|
|
->count(30)
|
|
->for($coupon)
|
|
->for($user)
|
|
->create();
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 30)
|
|
->where('redemptions.last_page', 2)
|
|
->where('redemptions.from', 1)
|
|
->where('redemptions.to', 25)
|
|
);
|
|
});
|
|
|
|
test('non-admin users cannot access redemptions page', function (): void {
|
|
$customer = User::factory()->create();
|
|
$customer->assignRole('customer');
|
|
|
|
$this->actingAs($customer)
|
|
->get('http://admin.ezscale.dev/coupons/redemptions')
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('guests cannot access redemptions page', function (): void {
|
|
auth()->logout();
|
|
|
|
$this->get('http://admin.ezscale.dev/coupons/redemptions')
|
|
->assertRedirect();
|
|
});
|
|
|
|
test('coupons list is available for filter dropdown', function (): void {
|
|
Coupon::factory()->create(['code' => 'ALPHA']);
|
|
Coupon::factory()->create(['code' => 'BETA']);
|
|
Coupon::factory()->create(['code' => 'GAMMA']);
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->has('coupons', 3)
|
|
->where('coupons.0.code', 'ALPHA')
|
|
->where('coupons.1.code', 'BETA')
|
|
->where('coupons.2.code', 'GAMMA')
|
|
);
|
|
});
|
|
|
|
test('redemptions with deleted users are cascade deleted', function (): void {
|
|
$coupon = Coupon::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
$redemption = CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create();
|
|
|
|
// Delete the user (cascades to redemption)
|
|
$user->delete();
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('Admin/Coupons/Redemptions')
|
|
->where('redemptions.total', 0)
|
|
);
|
|
});
|
|
|
|
test('redemptions can be exported to CSV', function (): void {
|
|
$coupon = Coupon::factory()->create(['code' => 'TEST50', 'type' => 'percentage', 'value' => '50.00']);
|
|
$user = User::factory()->create(['name' => 'John Doe', 'email' => 'john@example.com']);
|
|
|
|
CouponRedemption::factory()
|
|
->for($coupon)
|
|
->for($user)
|
|
->create(['discount_amount' => '25.00']);
|
|
|
|
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?export=csv');
|
|
|
|
$response->assertOk();
|
|
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
|
$response->assertHeader('Content-Disposition');
|
|
|
|
$content = $response->streamedContent();
|
|
|
|
expect($content)->toContain('Redemption ID')
|
|
->and($content)->toContain('Coupon Code')
|
|
->and($content)->toContain('TEST50')
|
|
->and($content)->toContain('percentage')
|
|
->and($content)->toContain('John Doe')
|
|
->and($content)->toContain('john@example.com')
|
|
->and($content)->toContain('25.00');
|
|
});
|
|
|
|
test('CSV export respects filters', function (): void {
|
|
$coupon1 = Coupon::factory()->create(['code' => 'COUPON1']);
|
|
$coupon2 = Coupon::factory()->create(['code' => 'COUPON2']);
|
|
$user = User::factory()->create();
|
|
|
|
CouponRedemption::factory()->for($coupon1)->for($user)->create();
|
|
CouponRedemption::factory()->for($coupon2)->for($user)->create();
|
|
|
|
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?export=csv&coupon_id={$coupon1->id}");
|
|
|
|
$response->assertOk();
|
|
|
|
$content = $response->streamedContent();
|
|
|
|
expect($content)->toContain('COUPON1')
|
|
->and($content)->not->toContain('COUPON2');
|
|
});
|