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:
451
website/tests/Feature/SupportTicketTest.php
Normal file
451
website/tests/Feature/SupportTicketTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user