Add standalone support ticket system with customer and admin interfaces

Replaces planned SupportPal integration with a built-in ticket system.
Customer side: create tickets, reply, close. Admin side: manage all
tickets with search/filters, staff replies, status updates. Includes
30 Pest tests (144 total, 775 assertions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 16:20:12 -05:00
parent 9603803928
commit 6f39c32270
23 changed files with 2343 additions and 76 deletions

View File

@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
$this->adminUrl = 'http://'.config('app.domains.admin');
});
// ---------------------------------------------------------------------------
// Customer Ticket Management
// ---------------------------------------------------------------------------
describe('Customer Ticket List', function (): void {
it('allows a customer to view their ticket list', function (): void {
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(3)->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->get($this->accountUrl.'/tickets')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Tickets/Index')
->has('tickets.data', 3)
);
});
it('customer cannot see other users tickets', function (): void {
$customer = User::factory()->customer()->create();
$otherUser = User::factory()->customer()->create();
SupportTicket::factory()->count(2)->create(['user_id' => $customer->id]);
SupportTicket::factory()->count(3)->create(['user_id' => $otherUser->id]);
$this->actingAs($customer)
->get($this->accountUrl.'/tickets')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Tickets/Index')
->has('tickets.data', 2)
);
});
it('redirects guests to login when accessing ticket list', function (): void {
$this->get($this->accountUrl.'/tickets')
->assertRedirect();
});
});
describe('Customer Ticket Creation', function (): void {
it('displays the create ticket form', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->accountUrl.'/tickets/create')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Tickets/Create')
);
});
it('allows a customer to create a ticket', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->post($this->accountUrl.'/tickets', [
'subject' => 'Unable to access VPS',
'department' => 'technical',
'priority' => 'high',
'message' => 'I cannot SSH into my VPS server. Please help.',
])
->assertRedirect();
$this->assertDatabaseHas('support_tickets', [
'user_id' => $customer->id,
'subject' => 'Unable to access VPS',
'department' => 'technical',
'priority' => 'high',
'status' => 'open',
]);
$ticket = SupportTicket::where('user_id', $customer->id)->first();
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
'body' => 'I cannot SSH into my VPS server. Please help.',
'is_staff_reply' => false,
]);
});
it('validates required fields when creating a ticket', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->post($this->accountUrl.'/tickets', [])
->assertSessionHasErrors(['subject', 'department', 'priority', 'message']);
});
it('requires authentication to create a ticket', function (): void {
$this->get($this->accountUrl.'/tickets/create')
->assertRedirect();
});
});
describe('Customer Ticket Detail', function (): void {
it('allows a customer to view their own ticket detail', 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,
]);
$this->actingAs($customer)
->get($this->accountUrl.'/tickets/'.$ticket->id)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Tickets/Show')
->has('ticket')
->has('ticket.replies', 2)
->where('ticket.id', $ticket->id)
);
});
it('forbids a customer from viewing another users ticket', function (): void {
$customer = User::factory()->customer()->create();
$otherUser = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $otherUser->id]);
$this->actingAs($customer)
->get($this->accountUrl.'/tickets/'.$ticket->id)
->assertForbidden();
});
});
describe('Customer Ticket Reply', function (): void {
it('allows a customer to reply to their open ticket', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
'body' => 'Here is additional information about my issue.',
])
->assertRedirect();
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
'body' => 'Here is additional information about my issue.',
'is_staff_reply' => false,
]);
expect($ticket->fresh()->last_reply_at)->not->toBeNull();
});
it('customer cannot reply to a closed ticket', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
'body' => 'Trying to reply to closed ticket.',
])
->assertForbidden();
});
it('customer cannot reply to another users ticket', function (): void {
$customer = User::factory()->customer()->create();
$otherUser = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
'body' => 'Unauthorized reply attempt.',
])
->assertForbidden();
});
it('validates reply body is required', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [])
->assertSessionHasErrors(['body']);
});
});
describe('Customer Ticket Close', function (): void {
it('allows a customer to close their ticket', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/close')
->assertRedirect();
expect($ticket->fresh()->status)->toBe('closed');
});
it('customer cannot close another users ticket', function (): void {
$customer = User::factory()->customer()->create();
$otherUser = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]);
$this->actingAs($customer)
->post($this->accountUrl.'/tickets/'.$ticket->id.'/close')
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Admin Ticket Management
// ---------------------------------------------------------------------------
describe('Admin Ticket List', function (): void {
it('allows admin to view all tickets', function (): void {
$admin = User::factory()->admin()->create();
$customer1 = User::factory()->customer()->create();
$customer2 = User::factory()->customer()->create();
SupportTicket::factory()->count(2)->create(['user_id' => $customer1->id]);
SupportTicket::factory()->count(3)->create(['user_id' => $customer2->id]);
$this->actingAs($admin)
->get($this->adminUrl.'/tickets')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Tickets/Index')
->has('tickets.data', 5)
->has('filters')
);
});
it('allows admin to filter tickets by status', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(2)->create([
'user_id' => $customer->id,
'status' => 'open',
]);
SupportTicket::factory()->count(3)->create([
'user_id' => $customer->id,
'status' => 'closed',
]);
$this->actingAs($admin)
->get($this->adminUrl.'/tickets?status=open')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Tickets/Index')
->has('tickets.data', 2)
);
});
it('allows admin to filter tickets by priority', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(1)->urgent()->create(['user_id' => $customer->id]);
SupportTicket::factory()->count(2)->create([
'user_id' => $customer->id,
'priority' => 'low',
]);
$this->actingAs($admin)
->get($this->adminUrl.'/tickets?priority=urgent')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Tickets/Index')
->has('tickets.data', 1)
);
});
it('allows admin to filter tickets by department', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(2)->create([
'user_id' => $customer->id,
'department' => 'billing',
]);
SupportTicket::factory()->count(3)->create([
'user_id' => $customer->id,
'department' => 'technical',
]);
$this->actingAs($admin)
->get($this->adminUrl.'/tickets?department=billing')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Tickets/Index')
->has('tickets.data', 2)
);
});
it('denies customer access to admin ticket list', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/tickets')
->assertForbidden();
});
it('redirects guest to login when accessing admin ticket list', function (): void {
$this->get($this->adminUrl.'/tickets')
->assertRedirect();
});
});
describe('Admin Ticket Detail', function (): void {
it('allows admin to view any ticket', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
TicketReply::factory()->count(3)->create(['ticket_id' => $ticket->id]);
$this->actingAs($admin)
->get($this->adminUrl.'/tickets/'.$ticket->id)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Tickets/Show')
->has('ticket')
->has('ticket.replies', 3)
->where('ticket.id', $ticket->id)
);
});
});
describe('Admin Ticket Reply', function (): void {
it('allows admin to reply to a ticket as staff', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [
'body' => 'Thank you for your inquiry. We are looking into this.',
])
->assertRedirect();
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $admin->id,
'body' => 'Thank you for your inquiry. We are looking into this.',
'is_staff_reply' => true,
]);
expect($ticket->fresh()->last_reply_at)->not->toBeNull();
});
it('admin can reply to a closed ticket', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [
'body' => 'Additional information regarding this closed ticket.',
])
->assertRedirect();
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $admin->id,
'body' => 'Additional information regarding this closed ticket.',
'is_staff_reply' => true,
]);
});
it('validates reply body is required for admin', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [])
->assertSessionHasErrors(['body']);
});
});
describe('Admin Ticket Status Update', function (): void {
it('allows admin to update ticket status', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
'status' => 'in_progress',
])
->assertRedirect();
expect($ticket->fresh()->status)->toBe('in_progress');
});
it('allows admin to change ticket to closed status', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
'status' => 'closed',
])
->assertRedirect();
expect($ticket->fresh()->status)->toBe('closed');
});
it('allows admin to reopen a closed ticket', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
'status' => 'open',
])
->assertRedirect();
expect($ticket->fresh()->status)->toBe('open');
});
it('validates status is required', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($admin)
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [])
->assertSessionHasErrors(['status']);
});
it('denies customer access to status update endpoint', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
$this->actingAs($customer)
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
'status' => 'closed',
])
->assertForbidden();
});
});