Remove old Vuexy wrapper components (AppTextField, AppSelect, AppTextarea, FlashMessages, NotificationBell)

All pages now use native Vuetify components directly. Flash messages are handled
by the ToastStack component via Pinia store. Notifications use NotificationPanel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-14 17:10:23 -04:00
parent dd1a5d7ffc
commit 40c1ecc6fe
90 changed files with 20113 additions and 457 deletions

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use App\Models\EmailTemplate;
use App\Models\User;
use Database\Seeders\EmailTemplateSeeder;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
// ---------------------------------------------------------------------------
// Index
// ---------------------------------------------------------------------------
describe('Email Template Index', function (): void {
it('allows admin to list email templates', function (): void {
$admin = User::factory()->admin()->create();
EmailTemplate::factory()->count(3)->create();
$this->actingAs($admin)
->get($this->adminUrl.'/email-templates')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/EmailTemplates/Index')
->has('templates', 3)
);
});
it('denies customer access to email templates', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/email-templates')
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Edit
// ---------------------------------------------------------------------------
describe('Email Template Edit', function (): void {
it('allows admin to view edit form', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create(['slug' => 'payment-succeeded']);
$this->actingAs($admin)
->get($this->adminUrl.'/email-templates/'.$template->id.'/edit')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/EmailTemplates/Edit')
->has('template')
->where('template.slug', 'payment-succeeded')
);
});
});
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
describe('Email Template Update', function (): void {
it('allows admin to update template subject and body', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'New Subject {{customer_name}}',
'body' => 'New body content with {{amount}}',
'is_active' => true,
])
->assertRedirect();
$template->refresh();
expect($template->subject)->toBe('New Subject {{customer_name}}');
expect($template->body)->toBe('New body content with {{amount}}');
});
it('allows admin to toggle active status', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create(['is_active' => true]);
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => $template->subject,
'body' => $template->body,
'is_active' => false,
])
->assertRedirect();
$template->refresh();
expect($template->is_active)->toBeFalse();
});
it('validates subject is required', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => '',
'body' => 'Some body',
'is_active' => true,
])
->assertSessionHasErrors('subject');
});
it('validates body is required', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'Some subject',
'body' => '',
'is_active' => true,
])
->assertSessionHasErrors('body');
});
it('denies non-admin from updating templates', function (): void {
$customer = User::factory()->customer()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($customer)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'Hacked',
'body' => 'Hacked body',
'is_active' => true,
])
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Preview
// ---------------------------------------------------------------------------
describe('Email Template Preview', function (): void {
it('allows admin to preview template with sample data', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create([
'slug' => 'payment-succeeded',
'subject' => 'Payment of {{currency}} {{amount}} Received',
'body' => 'Hello {{customer_name}}, your payment of {{currency}} {{amount}} was received.',
'available_variables' => ['customer_name', 'amount', 'currency', 'invoice_number', 'date'],
]);
$this->actingAs($admin)
->postJson($this->adminUrl.'/email-templates/'.$template->id.'/preview')
->assertOk()
->assertJsonStructure(['subject', 'body'])
->assertJsonFragment(['subject' => 'Payment of USD 49.99 Received']);
});
});
// ---------------------------------------------------------------------------
// Reset to Default
// ---------------------------------------------------------------------------
describe('Email Template Reset', function (): void {
it('allows admin to reset template to default', function (): void {
$admin = User::factory()->admin()->create();
// Seed the default template first
$this->seed(EmailTemplateSeeder::class);
$template = EmailTemplate::query()->where('slug', 'payment-succeeded')->first();
$originalSubject = $template->subject;
// Modify it
$template->update(['subject' => 'Custom Subject', 'body' => 'Custom body']);
$this->actingAs($admin)
->post($this->adminUrl.'/email-templates/'.$template->id.'/reset')
->assertRedirect();
$template->refresh();
expect($template->subject)->toBe($originalSubject);
});
});
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
describe('EmailTemplate Model', function (): void {
it('can get active template by slug', function (): void {
$template = EmailTemplate::factory()->create([
'slug' => 'test-template',
'is_active' => true,
]);
$found = EmailTemplate::getTemplate('test-template');
expect($found)->not->toBeNull();
expect($found->id)->toBe($template->id);
});
it('returns null for inactive template', function (): void {
EmailTemplate::factory()->create([
'slug' => 'inactive-template',
'is_active' => false,
]);
$found = EmailTemplate::getTemplate('inactive-template');
expect($found)->toBeNull();
});
it('can render template with variables', function (): void {
EmailTemplate::factory()->create([
'slug' => 'render-test',
'subject' => 'Hello {{customer_name}}',
'body' => 'Your amount is {{amount}} {{currency}}.',
'available_variables' => ['customer_name', 'amount', 'currency'],
'is_active' => true,
]);
$result = EmailTemplate::render('render-test', [
'customer_name' => 'Jane',
'amount' => '99.99',
'currency' => 'USD',
]);
expect($result)->not->toBeNull();
expect($result['subject'])->toBe('Hello Jane');
expect($result['body'])->toBe('Your amount is 99.99 USD.');
});
it('returns null when rendering non-existent template', function (): void {
$result = EmailTemplate::render('non-existent', ['customer_name' => 'Test']);
expect($result)->toBeNull();
});
});

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->admin = User::factory()->admin()->create();
$this->customer = User::factory()->customer()->create();
$this->plan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
]);
$this->subscription = Subscription::factory()->create([
'user_id' => $this->customer->id,
'type' => 'default',
'stripe_status' => 'active',
'ends_at' => now()->addDays(30),
]);
$this->service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
'provisioned_at' => now()->subDays(10),
]);
});
test('admin can extend service expiry date', function (): void {
$newDate = now()->addDays(60)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
'reason' => 'Customer loyalty extension',
]);
$response->assertSessionHas('success', 'Service expiry date has been extended successfully.');
// Verify subscription ends_at was updated
$this->subscription->refresh();
expect($this->subscription->ends_at->format('Y-m-d'))->toBe($newDate);
});
test('extend expiry creates audit log with old and new dates', function (): void {
$oldEndsAt = $this->subscription->ends_at;
$newDate = now()->addDays(90)->format('Y-m-d');
$this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
'reason' => 'Goodwill extension',
]);
$auditLog = AuditLog::where('action', 'extend_service_expiry')
->where('resource_id', $this->service->id)
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->admin_id)->toBe($this->admin->id);
expect($auditLog->user_id)->toBe($this->customer->id);
expect($auditLog->resource_type)->toBe('service');
expect($auditLog->changes)->toHaveKey('old_ends_at');
expect($auditLog->changes)->toHaveKey('new_ends_at');
expect($auditLog->changes['reason'])->toBe('Goodwill extension');
});
test('extend expiry requires date in the future', function (): void {
$pastDate = now()->subDays(5)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $pastDate,
]);
$response->assertSessionHasErrors(['new_expiry_date']);
});
test('extend expiry requires a date', function (): void {
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => '',
]);
$response->assertSessionHasErrors(['new_expiry_date']);
});
test('extend expiry fails for service without subscription', function (): void {
$serviceNoSub = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => null,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$serviceNoSub->id}/extend-expiry", [
'new_expiry_date' => now()->addDays(30)->format('Y-m-d'),
]);
$response->assertSessionHas('error', 'This service does not have an associated subscription.');
});
test('extend expiry works when subscription has no previous ends_at', function (): void {
$this->subscription->update(['ends_at' => null]);
$newDate = now()->addDays(45)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
]);
$response->assertSessionHas('success', 'Service expiry date has been extended successfully.');
$this->subscription->refresh();
expect($this->subscription->ends_at->format('Y-m-d'))->toBe($newDate);
// Audit log should have null for old_ends_at
$auditLog = AuditLog::where('action', 'extend_service_expiry')->first();
expect($auditLog->changes['old_ends_at'])->toBeNull();
});
test('extend expiry reason is optional', function (): void {
$newDate = now()->addDays(60)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
]);
$response->assertSessionHas('success');
$auditLog = AuditLog::where('action', 'extend_service_expiry')->first();
expect($auditLog->changes['reason'])->toBeNull();
});
test('non-admin cannot extend service expiry', function (): void {
$response = $this->actingAs($this->customer)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => now()->addDays(30)->format('Y-m-d'),
]);
$response->assertForbidden();
});
test('service show page includes subscription data', function (): void {
$response = $this->actingAs($this->admin)
->get("http://admin.ezscale.dev/services/{$this->service->id}");
$response->assertOk();
$props = $response->viewData('page')['props'];
$service = $props['service'];
expect($service)->toHaveKey('subscription');
expect($service['subscription'])->not->toBeNull();
expect($service['subscription'])->toHaveKey('ends_at');
expect($service['subscription'])->toHaveKey('current_period_end');
expect($service['subscription'])->toHaveKey('stripe_status');
});

View File

@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
use App\Models\TaxRate;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
// ---------------------------------------------------------------------------
// Index
// ---------------------------------------------------------------------------
describe('Tax Rate Index', function (): void {
it('allows admin to list tax rates', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->count(3)->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 3)
->has('countries')
->has('filters')
);
});
it('filters tax rates by country', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->forCountry('US')->create();
TaxRate::factory()->forCountry('DE')->create();
TaxRate::factory()->forCountry('US', 'CA')->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?country=US')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 2)
);
});
it('filters tax rates by active status', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->count(2)->create(['is_active' => true]);
TaxRate::factory()->inactive()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?status=active')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 2)
);
});
it('searches tax rates by name', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->create(['name' => 'EU VAT Germany']);
TaxRate::factory()->create(['name' => 'US Sales Tax']);
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?search=VAT')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 1)
);
});
it('denies non-admin access to tax rates', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/tax-rates')
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
describe('Tax Rate Create', function (): void {
it('displays the create tax rate page', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates/create')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Create')
);
});
it('stores a new tax rate', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'EU VAT - Germany',
'country_code' => 'DE',
'region_code' => null,
'rate' => 19.00,
'type' => 'inclusive',
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'name' => 'EU VAT - Germany',
'country_code' => 'DE',
'rate' => '19.00',
'type' => 'inclusive',
'is_active' => true,
]);
});
it('stores a tax rate with region code', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'US Sales Tax - California',
'country_code' => 'US',
'region_code' => 'CA',
'rate' => 7.25,
'type' => 'exclusive',
'priority' => 1,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'name' => 'US Sales Tax - California',
'country_code' => 'US',
'region_code' => 'CA',
'rate' => '7.25',
'type' => 'exclusive',
]);
});
it('uppercases country code on store', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Test Tax',
'country_code' => 'de',
'rate' => 10.00,
'type' => 'exclusive',
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'country_code' => 'DE',
]);
});
it('validates required fields when storing', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [])
->assertSessionHasErrors(['name', 'country_code', 'rate', 'type']);
});
it('validates rate range', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Invalid Rate',
'country_code' => 'US',
'rate' => 150,
'type' => 'exclusive',
])
->assertSessionHasErrors(['rate']);
});
it('validates country code format', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Bad Country',
'country_code' => 'USA',
'rate' => 10,
'type' => 'exclusive',
])
->assertSessionHasErrors(['country_code']);
});
it('validates type enum', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Bad Type',
'country_code' => 'US',
'rate' => 10,
'type' => 'invalid',
])
->assertSessionHasErrors(['type']);
});
});
// ---------------------------------------------------------------------------
// Edit / Update
// ---------------------------------------------------------------------------
describe('Tax Rate Edit', function (): void {
it('displays the edit tax rate page', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates/'.$taxRate->id.'/edit')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Edit')
->has('taxRate')
);
});
it('updates an existing tax rate', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create([
'name' => 'Old Name',
'rate' => '10.00',
]);
$this->actingAs($admin)
->put($this->adminUrl.'/tax-rates/'.$taxRate->id, [
'name' => 'New Name',
'country_code' => $taxRate->country_code,
'region_code' => $taxRate->region_code,
'rate' => 15.00,
'type' => $taxRate->type,
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$fresh = $taxRate->fresh();
expect($fresh->name)->toBe('New Name');
expect((float) $fresh->rate)->toBe(15.0);
});
});
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
describe('Tax Rate Delete', function (): void {
it('deletes a tax rate', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create();
$this->actingAs($admin)
->delete($this->adminUrl.'/tax-rates/'.$taxRate->id)
->assertRedirect();
$this->assertDatabaseMissing('tax_rates', ['id' => $taxRate->id]);
});
});
// ---------------------------------------------------------------------------
// Toggle Active
// ---------------------------------------------------------------------------
describe('Tax Rate Toggle Active', function (): void {
it('toggles a tax rate from active to inactive', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create(['is_active' => true]);
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates/'.$taxRate->id.'/toggle-active')
->assertRedirect();
expect($taxRate->fresh()->is_active)->toBeFalse();
});
it('toggles a tax rate from inactive to active', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->inactive()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates/'.$taxRate->id.'/toggle-active')
->assertRedirect();
expect($taxRate->fresh()->is_active)->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// Model: getApplicableRates
// ---------------------------------------------------------------------------
describe('TaxRate::getApplicableRates', function (): void {
it('returns matching rates for a country', function (): void {
TaxRate::factory()->forCountry('US')->create(['rate' => '8.00', 'priority' => 0]);
TaxRate::factory()->forCountry('DE')->create(['rate' => '19.00']);
$rates = TaxRate::getApplicableRates('US');
expect($rates)->toHaveCount(1);
expect((float) $rates->first()->rate)->toBe(8.0);
});
it('returns country-level and region-level rates when region is specified', function (): void {
TaxRate::factory()->forCountry('US')->create(['rate' => '5.00', 'priority' => 0]);
TaxRate::factory()->forCountry('US', 'CA')->create(['rate' => '7.25', 'priority' => 1]);
TaxRate::factory()->forCountry('US', 'NY')->create(['rate' => '8.00', 'priority' => 1]);
$rates = TaxRate::getApplicableRates('US', 'CA');
expect($rates)->toHaveCount(2);
expect((float) $rates->first()->rate)->toBe(5.0);
expect((float) $rates->last()->rate)->toBe(7.25);
});
it('excludes inactive rates', function (): void {
TaxRate::factory()->forCountry('GB')->create(['is_active' => true]);
TaxRate::factory()->forCountry('GB')->inactive()->create(['type' => 'inclusive']);
$rates = TaxRate::getApplicableRates('GB');
expect($rates)->toHaveCount(1);
});
it('orders rates by priority', function (): void {
TaxRate::factory()->forCountry('FR')->create([
'name' => 'Low Priority',
'rate' => '5.00',
'priority' => 2,
]);
TaxRate::factory()->forCountry('FR')->create([
'name' => 'High Priority',
'rate' => '20.00',
'priority' => 0,
'type' => 'inclusive',
]);
$rates = TaxRate::getApplicableRates('FR');
expect($rates)->toHaveCount(2);
expect($rates->first()->name)->toBe('High Priority');
expect($rates->last()->name)->toBe('Low Priority');
});
it('returns empty collection when no rates match', function (): void {
TaxRate::factory()->forCountry('US')->create();
$rates = TaxRate::getApplicableRates('ZZ');
expect($rates)->toBeEmpty();
});
});

View File

@@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Passport\Passport;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
beforeEach(function (): void {
Passport::$validateKeyPermissions = false;
$this->seed(RoleAndPermissionSeeder::class);
// Create roles and permissions for the 'api' guard so Passport auth works with Spatie
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$apiAdmin = Role::findOrCreate('admin', 'api');
Role::findOrCreate('customer', 'api');
$permissions = Permission::where('guard_name', 'web')->pluck('name');
foreach ($permissions as $permissionName) {
$perm = Permission::findOrCreate($permissionName, 'api');
$apiAdmin->givePermissionTo($perm);
}
});
/**
* Create an admin user with the 'api' guard role and authenticate via Passport.
*/
function createApiAdmin(): User
{
$admin = User::factory()->create();
$admin->assignRole(Role::findByName('admin', 'api'));
Passport::actingAs($admin);
return $admin;
}
/**
* Create a customer user with the 'api' guard role.
*/
function createApiCustomer(array $attributes = []): User
{
$customer = User::factory()->create($attributes);
$customer->assignRole(Role::findByName('customer', 'api'));
return $customer;
}
// ---------------------------------------------------------------------------
// Authentication & Authorization
// ---------------------------------------------------------------------------
describe('Authentication & Authorization', function (): void {
it('returns 401 for unauthenticated requests', function (): void {
$this->getJson('/api/v1/admin/customers')
->assertUnauthorized();
});
it('returns 403 for non-admin users accessing admin endpoints', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$this->getJson('/api/v1/admin/customers')
->assertForbidden();
});
it('returns 403 for non-admin accessing analytics', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$this->getJson('/api/v1/admin/analytics')
->assertForbidden();
});
it('returns 403 for non-admin trying to suspend a service', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$service = Service::factory()->create();
$this->postJson("/api/v1/admin/services/{$service->id}/suspend")
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Customer Management
// ---------------------------------------------------------------------------
describe('Customer Management', function (): void {
it('allows admin to list customers', function (): void {
$admin = createApiAdmin();
// Create customers with the 'web' guard role (used by User::role('customer') query)
User::factory()->customer()->count(5)->create();
$response = $this->getJson('/api/v1/admin/customers');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email', 'status', 'services_count', 'subscriptions_count', 'created_at'],
],
'links',
'meta',
]);
expect($response->json('meta.total'))->toBe(5);
});
it('allows admin to search customers by name', function (): void {
createApiAdmin();
User::factory()->customer()->create(['name' => 'John Searchable']);
User::factory()->customer()->create(['name' => 'Jane Other']);
$response = $this->getJson('/api/v1/admin/customers?search=Searchable');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.name'))->toBe('John Searchable');
});
it('allows admin to search customers by email', function (): void {
createApiAdmin();
User::factory()->customer()->create(['email' => 'unique-test@example.com']);
User::factory()->customer()->create(['email' => 'other@example.com']);
$response = $this->getJson('/api/v1/admin/customers?search=unique-test');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.email'))->toBe('unique-test@example.com');
});
it('allows admin to filter customers by status', function (): void {
createApiAdmin();
User::factory()->customer()->count(3)->create(['status' => 'active']);
User::factory()->customer()->suspended()->count(2)->create();
$response = $this->getJson('/api/v1/admin/customers?status=suspended');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to view customer details', function (): void {
createApiAdmin();
$customer = User::factory()->customer()->create();
Service::factory()->count(3)->create(['user_id' => $customer->id]);
$response = $this->getJson("/api/v1/admin/customers/{$customer->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'status', 'services_count', 'subscriptions_count', 'created_at'],
]);
expect($response->json('data.id'))->toBe($customer->id);
expect($response->json('data.services_count'))->toBe(3);
});
it('paginates customers with custom per_page', function (): void {
createApiAdmin();
User::factory()->customer()->count(10)->create();
$response = $this->getJson('/api/v1/admin/customers?per_page=5');
$response->assertOk();
expect($response->json('meta.per_page'))->toBe(5);
expect(count($response->json('data')))->toBe(5);
});
it('creates an audit log when listing customers', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/customers')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_list_customers',
'resource_type' => 'user',
]);
});
it('creates an audit log when viewing a customer', function (): void {
$admin = createApiAdmin();
$customer = User::factory()->customer()->create();
$this->getJson("/api/v1/admin/customers/{$customer->id}")
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_view_customer',
'resource_type' => 'user',
'resource_id' => $customer->id,
]);
});
});
// ---------------------------------------------------------------------------
// Service Management
// ---------------------------------------------------------------------------
describe('Service Management', function (): void {
it('allows admin to list all services', function (): void {
createApiAdmin();
Service::factory()->count(5)->create();
$response = $this->getJson('/api/v1/admin/services');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'user', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'plan', 'created_at'],
],
'links',
'meta',
]);
expect($response->json('meta.total'))->toBe(5);
});
it('allows admin to filter services by status', function (): void {
createApiAdmin();
Service::factory()->count(3)->create(['status' => 'active']);
Service::factory()->suspended()->count(2)->create();
$response = $this->getJson('/api/v1/admin/services?status=suspended');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to filter services by service_type', function (): void {
createApiAdmin();
Service::factory()->count(2)->create(['service_type' => 'vps', 'platform' => 'virtfusion']);
Service::factory()->count(3)->create(['service_type' => 'dedicated', 'platform' => 'synergycp']);
$response = $this->getJson('/api/v1/admin/services?service_type=vps');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to search services by customer name', function (): void {
createApiAdmin();
$customer = User::factory()->customer()->create(['name' => 'SearchTarget User']);
Service::factory()->create(['user_id' => $customer->id]);
Service::factory()->count(3)->create();
$response = $this->getJson('/api/v1/admin/services?search=SearchTarget');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
});
it('allows admin to view service details', function (): void {
createApiAdmin();
$service = Service::factory()->create();
$response = $this->getJson("/api/v1/admin/services/{$service->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'user', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'plan', 'created_at'],
]);
expect($response->json('data.id'))->toBe($service->id);
});
it('allows admin to suspend a service', function (): void {
$admin = createApiAdmin();
$service = Service::factory()->create(['status' => 'active']);
$response = $this->postJson("/api/v1/admin/services/{$service->id}/suspend");
$response->assertOk()
->assertJson(['message' => 'Service has been suspended.']);
$service->refresh();
expect($service->status)->toBe('suspended');
expect($service->suspended_at)->not->toBeNull();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_suspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
]);
});
it('returns 422 when suspending an already suspended service', function (): void {
createApiAdmin();
$service = Service::factory()->suspended()->create();
$response = $this->postJson("/api/v1/admin/services/{$service->id}/suspend");
$response->assertUnprocessable()
->assertJson(['message' => 'Service is already suspended.']);
});
it('allows admin to unsuspend a service', function (): void {
$admin = createApiAdmin();
$service = Service::factory()->suspended()->create();
$response = $this->postJson("/api/v1/admin/services/{$service->id}/unsuspend");
$response->assertOk()
->assertJson(['message' => 'Service has been unsuspended.']);
$service->refresh();
expect($service->status)->toBe('active');
expect($service->suspended_at)->toBeNull();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_unsuspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
]);
});
it('returns 422 when unsuspending a non-suspended service', function (): void {
createApiAdmin();
$service = Service::factory()->create(['status' => 'active']);
$response = $this->postJson("/api/v1/admin/services/{$service->id}/unsuspend");
$response->assertUnprocessable()
->assertJson(['message' => 'Service is not suspended.']);
});
it('creates an audit log when listing services', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/services')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_list_services',
'resource_type' => 'service',
]);
});
});
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
describe('Analytics', function (): void {
it('allows admin to view analytics', function (): void {
createApiAdmin();
// Create some data for analytics — customers must have 'web' guard role
// for User::role('customer') queries in AnalyticsController
User::factory()->customer()->count(3)->create();
Service::factory()->count(2)->create(['status' => 'active']);
Invoice::factory()->count(2)->create(['status' => 'paid', 'total' => 50.00, 'paid_at' => now()]);
Invoice::factory()->create(['status' => 'pending', 'total' => 25.00]);
Invoice::factory()->create(['status' => 'overdue', 'total' => 30.00]);
$response = $this->getJson('/api/v1/admin/analytics');
$response->assertOk()
->assertJsonStructure([
'data' => [
'total_customers',
'new_customers_this_month',
'mrr',
'arr',
'total_revenue',
'revenue_this_month',
'active_services',
'pending_invoices' => ['count', 'amount'],
'overdue_invoices' => ['count', 'amount'],
'revenue_by_month',
'customer_growth',
'churn_data',
'revenue_by_service_type',
],
]);
expect($response->json('data.total_customers'))->toBe(3);
expect($response->json('data.active_services'))->toBe(2);
expect((float) $response->json('data.total_revenue'))->toBe(100.0);
expect($response->json('data.pending_invoices.count'))->toBe(1);
expect((float) $response->json('data.pending_invoices.amount'))->toBe(25.0);
expect($response->json('data.overdue_invoices.count'))->toBe(1);
expect((float) $response->json('data.overdue_invoices.amount'))->toBe(30.0);
});
it('creates an audit log when viewing analytics', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/analytics')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_view_analytics',
'resource_type' => 'analytics',
]);
});
it('returns churn data with correct structure', function (): void {
createApiAdmin();
$response = $this->getJson('/api/v1/admin/analytics');
$response->assertOk();
$churnData = $response->json('data.churn_data');
expect($churnData)->toBeArray();
expect(count($churnData))->toBe(6);
foreach ($churnData as $month) {
expect($month)->toHaveKeys(['month', 'rate', 'cancelled']);
}
});
});

View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\Service;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use App\Services\Provisioning\VirtFusionService;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Passport\Passport;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
Passport::$validateKeyPermissions = false;
});
// ---------------------------------------------------------------------------
// Services
// ---------------------------------------------------------------------------
describe('Services API', function (): void {
it('lists customer services paginated', function (): void {
$customer = User::factory()->customer()->create();
Service::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/services')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('shows a single service with plan details', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/services/{$service->id}")
->assertOk()
->assertJsonStructure([
'data' => ['id', 'service_type', 'platform', 'status', 'hostname', 'plan', 'created_at'],
])
->assertJsonPath('data.id', $service->id);
});
it('returns 403 when accessing another user service', function (): void {
$customer = User::factory()->customer()->create();
$otherCustomer = User::factory()->customer()->create();
$service = Service::factory()->create(['user_id' => $otherCustomer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/services/{$service->id}")
->assertForbidden();
});
it('reboots a VPS service', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create([
'user_id' => $customer->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
'status' => 'active',
]);
$mock = Mockery::mock(VirtFusionService::class);
$mock->shouldReceive('restart')->once()->andReturn(true);
$this->app->instance(VirtFusionService::class, $mock);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertOk()
->assertJsonPath('message', 'VPS reboot initiated successfully.');
});
it('rejects reboot for non-VPS services', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create([
'user_id' => $customer->id,
'service_type' => 'dedicated',
'platform' => 'synergycp',
'status' => 'active',
]);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertUnprocessable()
->assertJsonPath('message', 'Reboot is only available for VPS services.');
});
it('rejects reboot for inactive services', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->suspended()->create([
'user_id' => $customer->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
]);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertUnprocessable()
->assertJsonPath('message', 'Service must be active to reboot.');
});
});
// ---------------------------------------------------------------------------
// Invoices
// ---------------------------------------------------------------------------
describe('Invoices API', function (): void {
it('lists customer invoices paginated', function (): void {
$customer = User::factory()->customer()->create();
Invoice::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'number', 'total', 'tax', 'currency', 'status', 'due_date', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('filters invoices by status', function (): void {
$customer = User::factory()->customer()->create();
Invoice::factory()->count(2)->create(['user_id' => $customer->id, 'status' => 'paid']);
Invoice::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'overdue']);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices?status=paid')
->assertOk()
->assertJsonCount(2, 'data');
});
it('does not show invoices from other users', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
Invoice::factory()->count(2)->create(['user_id' => $customer->id]);
Invoice::factory()->count(3)->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices')
->assertOk()
->assertJsonCount(2, 'data');
});
it('downloads invoice PDF', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/invoices/{$invoice->id}/pdf")
->assertOk()
->assertHeader('content-type', 'application/pdf');
});
it('returns 403 when downloading another user invoice PDF', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$invoice = Invoice::factory()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/invoices/{$invoice->id}/pdf")
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Subscriptions
// ---------------------------------------------------------------------------
describe('Subscriptions API', function (): void {
it('lists customer subscriptions', function (): void {
$customer = User::factory()->customer()->create();
// Create subscriptions via Cashier's table directly
$customer->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test_'.uniqid(),
'stripe_status' => 'active',
'stripe_price' => 'price_test',
]);
Passport::actingAs($customer);
$this->getJson('/api/v1/subscriptions')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'stripe_status', 'created_at'],
],
])
->assertJsonCount(1, 'data');
});
});
// ---------------------------------------------------------------------------
// Tickets
// ---------------------------------------------------------------------------
describe('Tickets API', function (): void {
it('lists customer tickets paginated', function (): void {
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/tickets')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'reference', 'subject', 'status', 'priority', 'department', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('creates a new ticket', function (): void {
$customer = User::factory()->customer()->create();
// Create an admin for notification
User::factory()->admin()->create();
Passport::actingAs($customer);
$this->postJson('/api/v1/tickets', [
'subject' => 'Test Ticket Subject',
'message' => 'This is a detailed test message for the ticket body.',
'priority' => 'medium',
'department' => 'technical',
])
->assertCreated()
->assertJsonStructure([
'data' => ['id', 'reference', 'subject', 'status', 'priority', 'department', 'replies'],
])
->assertJsonPath('data.subject', 'Test Ticket Subject')
->assertJsonPath('data.status', 'open');
$this->assertDatabaseHas('support_tickets', [
'user_id' => $customer->id,
'subject' => 'Test Ticket Subject',
'priority' => 'medium',
'department' => 'technical',
]);
});
it('validates ticket creation data', function (): void {
$customer = User::factory()->customer()->create();
Passport::actingAs($customer);
$this->postJson('/api/v1/tickets', [])
->assertUnprocessable()
->assertJsonValidationErrors(['subject', 'message', 'priority', 'department']);
});
it('shows a ticket with replies', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
TicketReply::factory()->count(2)->create([
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
]);
Passport::actingAs($customer);
$this->getJson("/api/v1/tickets/{$ticket->id}")
->assertOk()
->assertJsonStructure([
'data' => [
'id', 'reference', 'subject', 'status',
'replies' => [
'*' => ['id', 'body', 'is_staff_reply', 'user', 'created_at'],
],
],
])
->assertJsonCount(2, 'data.replies');
});
it('returns 403 when viewing another user ticket', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/tickets/{$ticket->id}")
->assertForbidden();
});
it('replies to an open ticket', function (): void {
$customer = User::factory()->customer()->create();
User::factory()->admin()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'This is my reply to the ticket.',
])
->assertOk()
->assertJsonStructure([
'data' => ['id', 'replies'],
]);
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
'body' => 'This is my reply to the ticket.',
]);
});
it('cannot reply to a closed ticket', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'Trying to reply to closed ticket.',
])
->assertUnprocessable()
->assertJsonPath('message', 'Cannot reply to a closed ticket.');
});
it('cannot reply to another user ticket', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'Trying to reply to someone else ticket.',
])
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
describe('API Authentication', function (): void {
it('returns 401 for unauthenticated requests to services', function (): void {
$this->getJson('/api/v1/services')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to invoices', function (): void {
$this->getJson('/api/v1/invoices')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to subscriptions', function (): void {
$this->getJson('/api/v1/subscriptions')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to tickets', function (): void {
$this->getJson('/api/v1/tickets')
->assertUnauthorized();
});
});