From 8a41ae6ac1ebf9faeeb37420dcc625bb1783eb80 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 28 May 2026 13:22:36 +0000 Subject: [PATCH] Feat: assigned user status change, search, pagination and notification - Added activity logs for ticket status changes, comments, and ticket views. - Implemented `TicketStatusChangedNotification` to notify users of status updates. - Enhanced ticket list filtering with pagination, search, and status filters. - Updated UI to better handle ticket assignments, status updates, and comment notifications. - Extended tests for activity logs, unauthorized actions, status change notifications, and filtering functionalities. --- app/Data/Ui/Ticket/TicketListData.php | 2 + .../ResolveNotificationRouteController.php | 5 +- .../TicketStatusChangedNotification.php | 69 +++++ app/Services/TicketService.php | 130 +++++++++- .../views/pages/tickets/⚡index.blade.php | 100 ++++--- tests/Feature/TicketActivityLogTest.php | 244 ++++++++++++++++++ .../Feature/TicketSearchAndPaginationTest.php | 188 ++++++++++++++ tests/Feature/TicketSystemTest.php | 5 +- 8 files changed, 689 insertions(+), 54 deletions(-) create mode 100644 app/Notifications/TicketStatusChangedNotification.php create mode 100644 tests/Feature/TicketActivityLogTest.php create mode 100644 tests/Feature/TicketSearchAndPaginationTest.php diff --git a/app/Data/Ui/Ticket/TicketListData.php b/app/Data/Ui/Ticket/TicketListData.php index 714c29d..cac6250 100644 --- a/app/Data/Ui/Ticket/TicketListData.php +++ b/app/Data/Ui/Ticket/TicketListData.php @@ -19,6 +19,7 @@ public function __construct( public string $createdAt, public TicketStatusEnum $status, public string $statusSlug, + public ?int $assignedUserId = null, ) {} public static function fromModel(Ticket $ticket): self @@ -32,6 +33,7 @@ public static function fromModel(Ticket $ticket): self createdAt: $ticket->created_at->toDateTimeString(), status: TicketStatusEnum::from($ticket->status->name), statusSlug: $ticket->status->name, + assignedUserId: $ticket->assigned_user_id, ); } } diff --git a/app/Http/Controllers/ResolveNotificationRouteController.php b/app/Http/Controllers/ResolveNotificationRouteController.php index 773f2de..0f6be7e 100644 --- a/app/Http/Controllers/ResolveNotificationRouteController.php +++ b/app/Http/Controllers/ResolveNotificationRouteController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; -use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification}; +use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification}; use Illuminate\Http\Request; class ResolveNotificationRouteController extends Controller @@ -23,7 +23,8 @@ public function __invoke(Request $request, string $id) $url = match ($notification->type) { TicketRaisedNotification::class, TicketAssignedNotification::class, - TicketCommentedNotification::class => route('tickets.index', [ + TicketCommentedNotification::class, + TicketStatusChangedNotification::class => route('tickets.index', [ 'ticket' => data_get($notification->data, 'ticket_id'), ]), diff --git a/app/Notifications/TicketStatusChangedNotification.php b/app/Notifications/TicketStatusChangedNotification.php new file mode 100644 index 0000000..98bb6da --- /dev/null +++ b/app/Notifications/TicketStatusChangedNotification.php @@ -0,0 +1,69 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name); + + return (new MailMessage()) + ->subject("SSO Support Ticket Status Changed: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("The status of your support ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category} has been updated.") + ->line("New Status: {$statusLabel}") + ->action('View Ticket Details', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + $statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name); + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $this->ticket->user->name, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "The status of your support ticket {$this->ticket->ticket_id} has been changed to {$statusLabel}.", + ]; + } +} diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index e122b6f..e5d422b 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -8,12 +8,13 @@ use App\Data\Ui\Ticket\{ReplyData, TicketData, TicketListData}; use App\Enums\TicketStatusEnum; use App\Models\{Ticket, TicketReply, TicketStatus, User}; -use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification}; +use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification}; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use InvalidArgumentException; +use Spatie\LaravelData\PaginatedDataCollection; use Throwable; final class TicketService @@ -56,11 +57,11 @@ public function getAll(): Collection } /** - * Get lightweight ticket list for a specific user. + * Get lightweight ticket list for a specific user with search and status filtering. * - * @return Collection + * @return PaginatedDataCollection */ - public function getListForUser(int $userId): Collection + public function getListForUser(int $userId, ?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection { if ($userId <= 0) { throw new InvalidArgumentException('Invalid user ID'); @@ -80,24 +81,40 @@ public function getListForUser(int $userId): Collection $q->whereIn('roles.id', $roleIds); }); }) + ->when($search, function ($query) use ($search): void { + $query->where('ticket_id', 'like', "%{$search}%"); + }) + ->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void { + $query->whereHas('status', function ($q) use ($statusName): void { + $q->where('name', $statusName); + }); + }) ->latest() - ->get(); + ->paginate($perPage); - return TicketListData::collect($tickets); + return TicketListData::collect($tickets, PaginatedDataCollection::class); } /** - * Get lightweight ticket list of all tickets. + * Get lightweight ticket list of all tickets with search and status filtering. * - * @return Collection + * @return PaginatedDataCollection */ - public function getList(): Collection + public function getList(?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection { $tickets = Ticket::with(['user', 'connectedApp', 'status']) + ->when($search, function ($query) use ($search): void { + $query->where('ticket_id', 'like', "%{$search}%"); + }) + ->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void { + $query->whereHas('status', function ($q) use ($statusName): void { + $q->where('name', $statusName); + }); + }) ->latest() - ->get(); + ->paginate($perPage); - return TicketListData::collect($tickets); + return TicketListData::collect($tickets, PaginatedDataCollection::class); } /** @@ -112,6 +129,15 @@ public function getTicket(int $id): TicketData $ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']) ->findOrFail($id); + $user = auth()->user(); + if ($user) { + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('ticket_opened') + ->log("Ticket {$ticket->ticket_id} opened by {$user->name}"); + } + return TicketData::fromModel($ticket); } @@ -128,9 +154,37 @@ public function updateStatus(int $ticketId, string $statusSlug): void DB::transaction(function () use ($ticketId, $statusSlug): void { $ticket = Ticket::findOrFail($ticketId); - $status = TicketStatus::where('name', $statusSlug)->firstOrFail(); - $ticket->update(['status_id' => $status->id]); + $user = auth()->user(); + if (! $user) { + throw new AuthorizationException('Unauthenticated'); + } + + if (! $user->hasRole('Admin') && ! $user->hasRole('admin') && ! $user->can('ticket:update') && $ticket->assigned_user_id !== $user->id) { + throw new AuthorizationException('You are not authorized to update this ticket\'s status.'); + } + + $status = TicketStatus::where('name', $statusSlug)->firstOrFail(); + $oldStatus = $ticket->status->name; + + if ($oldStatus !== $status->name) { + $ticket->update(['status_id' => $status->id]); + + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('status_changed') + ->withProperties([ + 'old_status' => $oldStatus, + 'new_status' => $status->name, + ]) + ->log("Status updated from {$oldStatus} to {$status->name}"); + + if ($ticket->user_id !== $user->id) { + $ticket->user->notify(new TicketStatusChangedNotification($ticket)); + $this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class); + } + } }); } @@ -154,6 +208,7 @@ public function assignUser(int $ticketId, ?int $assignedUserId): void if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) { $assignedUser = User::findOrFail($assignedUserId); $assignedUser->notify(new TicketAssignedNotification($ticket)); + $this->logNotificationActivity($ticket, $assignedUser, TicketAssignedNotification::class); } }); } @@ -188,23 +243,51 @@ public function postReply(PostReplyRequest $request): ReplyData 'message' => mb_trim($request->message), ]); + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('comment_posted') + ->withProperties([ + 'comment_id' => $reply->id, + 'message' => $reply->message, + ]) + ->log("Comment posted on ticket {$ticket->ticket_id}"); + // Transition ticket status from open to in-progress if it's currently open $ticket->load(['status', 'user', 'assignedUser']); if ($ticket->status->name === TicketStatusEnum::Open->value) { $inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first(); if ($inProgressStatus) { + $oldStatus = $ticket->status->name; $ticket->update(['status_id' => $inProgressStatus->id]); + + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('status_changed') + ->withProperties([ + 'old_status' => $oldStatus, + 'new_status' => TicketStatusEnum::InProgress->value, + ]) + ->log("Status updated from {$oldStatus} to ".TicketStatusEnum::InProgress->value); + + if ($ticket->user_id !== $user->id) { + $ticket->user->notify(new TicketStatusChangedNotification($ticket)); + $this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class); + } } } // Notify ticket raiser if they did not make this comment if ($ticket->user_id !== $request->userId) { $ticket->user->notify(new TicketCommentedNotification($ticket, $reply)); + $this->logNotificationActivity($ticket, $ticket->user, TicketCommentedNotification::class); } // Notify assigned support user if they are set and did not make this comment if ($ticket->assigned_user_id && $ticket->assigned_user_id !== $request->userId) { $ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply)); + $this->logNotificationActivity($ticket, $ticket->assignedUser, TicketCommentedNotification::class); } return ReplyData::fromModel($reply->load('user.roles')); @@ -264,6 +347,7 @@ private function sendNotifications(Ticket $ticket): void // Notify specific users foreach ($ticket->notifiedUsers as $user) { $user->notify($notification); + $this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class); $notifiedUserIds[] = $user->id; } @@ -274,6 +358,7 @@ private function sendNotifications(Ticket $ticket): void // Avoid double notifications if (! in_array($user->id, $notifiedUserIds, true)) { $user->notify($notification); + $this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class); $notifiedUserIds[] = $user->id; } } @@ -286,8 +371,27 @@ private function sendNotifications(Ticket $ticket): void foreach ($admins as $admin) { if (! in_array($admin->id, $notifiedUserIds, true)) { $admin->notify($notification); + $this->logNotificationActivity($ticket, $admin, TicketRaisedNotification::class); $notifiedUserIds[] = $admin->id; } } } + + /** + * Log notification dispatch activity. + */ + private function logNotificationActivity(Ticket $ticket, User $recipient, string $notificationClass): void + { + $user = auth()->user(); + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('user_notified') + ->withProperties([ + 'recipient_id' => $recipient->id, + 'recipient_name' => $recipient->name, + 'notification_type' => $notificationClass, + ]) + ->log("Notified user {$recipient->name} about ticket {$ticket->ticket_id}"); + } } diff --git a/resources/views/pages/tickets/⚡index.blade.php b/resources/views/pages/tickets/⚡index.blade.php index 040ab1d..b2abfb4 100644 --- a/resources/views/pages/tickets/⚡index.blade.php +++ b/resources/views/pages/tickets/⚡index.blade.php @@ -5,6 +5,7 @@ use App\Data\Ticket\CreateTicketRequest; use App\Data\Ticket\PostReplyRequest; use App\Data\Ui\Ticket\TicketData; +use App\Data\Ui\Ticket\TicketListData; use App\Enums\DefaultUserRole; use App\Enums\Permissions\TicketPermissionEnum; use App\Enums\TicketStatusEnum; @@ -15,12 +16,14 @@ use Livewire\Attributes\{Computed, Layout, Title, Url}; use Livewire\Component; use Livewire\WithFileUploads; +use Livewire\WithPagination; +use Spatie\LaravelData\PaginatedDataCollection; new #[Layout('layouts.app.sidebar')] #[Title('Support Tickets')] class extends Component { - use WithFileUploads, HandlesOperations; + use WithFileUploads, HandlesOperations, WithPagination; private TicketService $ticketService; private UserAppAccessService $appAccessService; @@ -60,10 +63,23 @@ class extends Component { // Selected status for filtering (Admin/Support only) public string $statusFilter = 'all'; + // Ticket search keyword by code/ID + public string $search = ''; + // Comment and assignment state public string $replyMessage = ''; public $assignedUserId = null; + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void { $this->ticketService = $ticketService; @@ -121,26 +137,21 @@ public function rendering(): void * Compute visible tickets based on permissions. */ #[Computed] - public function tickets(): Collection + public function tickets(): PaginatedDataCollection { $user = auth()->user(); if (!$user) { - return collect(); + return TicketListData::collect(collect(), PaginatedDataCollection::class); } + $search = trim($this->search); + $search = empty($search) ? null : $search; + if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) { - $tickets = $this->ticketService->getList(); - if ($this->statusFilter !== 'all') { - $tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter); - } - return $tickets; + return $this->ticketService->getList($search, $this->statusFilter, 10); } - $tickets = $this->ticketService->getListForUser($user->id); - if ($this->statusFilter !== 'all') { - $tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter); - } - return $tickets; + return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10); } /** @@ -511,16 +522,29 @@ public function toggleDrawer(): void ?>
+ @if(!auth()->user()->hasRole('Admin') && !auth()->user()->hasRole('admin') && $this->eligibleApps->isEmpty()) + + You do not currently have any active services expiring within 3 days. You can still submit a general + inquiry ticket without choosing a service. + + @endif + - @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read')) - - @endif + + + - @if($this->tickets->isEmpty()) + @if($this->tickets->items()->isEmpty())
-

No Support Tickets Found

-

- You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal queries. -

+ @if(!empty(trim($search)) || $statusFilter !== 'all') +

No Matching Tickets Found

+

+ We couldn't find any support tickets matching your search query or filter criteria. Try + adjusting your search term or status filter. +

+ @else +

No Support Tickets Found

+

+ You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal + queries. +

+ @endif
@else 'createdAt', 'label' => 'Date Raised'], ['key' => 'statusName', 'label' => 'Status'], ]" - :rows="$this->tickets" + :rows="$this->tickets->items()" + with-pagination wire:loading.class="opacity-60" > @scope('cell_ticketId', $row) @@ -601,7 +635,7 @@ class="btn-xs btn-circle btn-ghost text-gray-500" tooltip="View Details" /> - @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update')) + @if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || $row->assignedUserId === auth()->id()) class="w-11/12 md:w-5/12 p-6 bg-white" > - - @if($this->eligibleApps->isEmpty()) - - You do not currently have any active services expiring within 3 days. You can still submit a general - inquiry ticket without choosing a service. - - @endif - @endif - - @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update')) + + @if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || auth()->id() === $selectedTicket->assignedUserId)
Change Status
diff --git a/tests/Feature/TicketActivityLogTest.php b/tests/Feature/TicketActivityLogTest.php new file mode 100644 index 0000000..651c502 --- /dev/null +++ b/tests/Feature/TicketActivityLogTest.php @@ -0,0 +1,244 @@ +seed(DatabaseSeeder::class); +}); + +test('assigned support user can change status of their assigned ticket and it logs activity', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('user'); // normal user assigned as support staff + + // Create ticket via service + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + // Assign the ticket to the support user + $service->assignUser($ticket->id, $supportUser->id); + + // Act as the assigned support user + $this->actingAs($supportUser); + + // Status change should be successful + $service->updateStatus($ticket->id, TicketStatusEnum::Hold->value); + + expect($ticket->fresh()->status->name)->toBe(TicketStatusEnum::Hold->value); + + // Verify activity log + $activity = Activity::where('subject_id', $ticket->id) + ->where('event', 'status_changed') + ->latest() + ->first(); + + expect($activity)->not->toBeNull() + ->and($activity->causer_id)->toBe($supportUser->id) + ->and($activity->properties['old_status'])->toBe(TicketStatusEnum::Open->value) + ->and($activity->properties['new_status'])->toBe(TicketStatusEnum::Hold->value) + ->and($activity->description)->toContain('Status updated from open to hold'); +}); + +test('unauthorized users cannot change status of ticket', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $otherUser = User::factory()->create(); + $otherUser->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + // Act as other user + $this->actingAs($otherUser); + + // Changing status should fail with authorization exception + $this->expectException(Illuminate\Auth\Access\AuthorizationException::class); + $service->updateStatus($ticketData->id, TicketStatusEnum::Hold->value); +}); + +test('commenting on a ticket logs comment activity and status transition', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + $this->actingAs($user); + + $replyDto = new PostReplyRequest( + ticketId: $ticket->id, + userId: $user->id, + message: 'This is a test comment.', + ); + + $service->postReply($replyDto); + + // Verify comment activity log + $commentActivity = Activity::where('subject_id', $ticket->id) + ->where('event', 'comment_posted') + ->first(); + + expect($commentActivity)->not->toBeNull() + ->and($commentActivity->properties['message'])->toBe('This is a test comment.') + ->and($commentActivity->description)->toContain('Comment posted on ticket'); + + // Verify transition status changed log (Open -> In Progress) + $statusActivity = Activity::where('subject_id', $ticket->id) + ->where('event', 'status_changed') + ->first(); + + expect($statusActivity)->not->toBeNull() + ->and($statusActivity->properties['old_status'])->toBe(TicketStatusEnum::Open->value) + ->and($statusActivity->properties['new_status'])->toBe(TicketStatusEnum::InProgress->value) + ->and($statusActivity->description)->toContain('Status updated from open to in-progress'); +}); + +test('getting/opening a ticket logs ticket_opened activity', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $this->actingAs($user); + + // Retrieve ticket details + $service->getTicket($ticketData->id); + + // Verify ticket opened activity log + $activity = Activity::where('subject_id', $ticketData->id) + ->where('event', 'ticket_opened') + ->first(); + + expect($activity)->not->toBeNull() + ->and($activity->causer_id)->toBe($user->id) + ->and($activity->description)->toContain('opened by'); +}); + +test('notifying users logs user_notified activities', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $this->actingAs($user); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [$admin->id], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + // Verify user notified activity logs + $activities = Activity::where('subject_id', Ticket::latest()->first()->id) + ->where('event', 'user_notified') + ->get(); + + // Should log notifications for the specifically notified admin user and all seeded admins + expect($activities->count())->toBeGreaterThan(0); + + $adminNotificationLog = $activities->first(fn ($act) => $act->properties['recipient_id'] === $admin->id); + expect($adminNotificationLog)->not->toBeNull() + ->and($adminNotificationLog->properties['recipient_name'])->toBe($admin->name) + ->and($adminNotificationLog->description)->toContain('Notified user'); +}); + +test('status changes trigger status changed notification to the user', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + // Act as admin to change status + $this->actingAs($admin); + + $service->updateStatus($ticket->id, TicketStatusEnum::Hold->value); + + // The user who raised the ticket should receive the status changed notification + Notification::assertSentTo( + $user, + TicketStatusChangedNotification::class + ); +}); diff --git a/tests/Feature/TicketSearchAndPaginationTest.php b/tests/Feature/TicketSearchAndPaginationTest.php new file mode 100644 index 0000000..96a9c55 --- /dev/null +++ b/tests/Feature/TicketSearchAndPaginationTest.php @@ -0,0 +1,188 @@ +seed(DatabaseSeeder::class); +}); + +test('anyone can search tickets by ticket id', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + + // Create Ticket 1 + $dto1 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'First test ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket1Data = $service->create($dto1); + + // Create Ticket 2 + $dto2 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Billing Query', + description: 'Second test ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket2Data = $service->create($dto2); + + // Search by Ticket 1 code/ID using Livewire component + Livewire::test('pages::tickets.index') + ->set('search', $ticket1Data->ticketId) + ->assertSee($ticket1Data->ticketId) + ->assertDontSee($ticket2Data->ticketId); + + // Search by Ticket 2 code/ID + Livewire::test('pages::tickets.index') + ->set('search', $ticket2Data->ticketId) + ->assertSee($ticket2Data->ticketId) + ->assertDontSee($ticket1Data->ticketId); +}); + +test('tickets table is paginated to 10 items per page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + + // Create 12 tickets + $createdTicketIds = []; + for ($i = 1; $i <= 12; $i++) { + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: "Ticket number {$i}.", + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticketData = $service->create($dto); + Ticket::where('id', $ticketData->id)->update([ + 'created_at' => now()->addSeconds($i), + ]); + $createdTicketIds[] = $ticketData->ticketId; + } + + $component = Livewire::test('pages::tickets.index'); + + // On page 1, we should see the latest 10 tickets (since we order by latest, the last 10 created) + $page1Tickets = array_slice(array_reverse($createdTicketIds), 0, 10); + $page2Tickets = array_slice(array_reverse($createdTicketIds), 10, 2); + + foreach ($page1Tickets as $id) { + $component->assertSee($id); + } + + foreach ($page2Tickets as $id) { + $component->assertDontSee($id); + } + + // Go to page 2 + $component->call('gotoPage', 2); + + foreach ($page1Tickets as $id) { + $component->assertDontSee($id); + } + + foreach ($page2Tickets as $id) { + $component->assertSee($id); + } +}); + +test('status filtering works on paginated data at query level', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + // Create Ticket 1 (Open status by default) + $dto1 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'Open ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket1Data = $service->create($dto1); + + // Create Ticket 2 and change its status to Hold + $dto2 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Billing Query', + description: 'Hold ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket2Data = $service->create($dto2); + + // Act as admin to change status + $this->actingAs($admin); + $service->updateStatus($ticket2Data->id, TicketStatusEnum::Hold->value); + + // Act as user to view + $this->actingAs($user); + + // Filter by Open + Livewire::test('pages::tickets.index') + ->set('statusFilter', TicketStatusEnum::Open->value) + ->assertSee($ticket1Data->ticketId) + ->assertDontSee($ticket2Data->ticketId); + + // Filter by Hold + Livewire::test('pages::tickets.index') + ->set('statusFilter', TicketStatusEnum::Hold->value) + ->assertSee($ticket2Data->ticketId) + ->assertDontSee($ticket1Data->ticketId); +}); + +test('normal user can see status filter and see custom empty state message when no results match search', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + // Initial load: Should see default empty state message + Livewire::test('pages::tickets.index') + ->assertSee('No Support Tickets Found') + ->assertDontSee('No Matching Tickets Found') + ->assertSee('You have not raised any support tickets yet.'); + + // Search for non-existent ticket ID: Should see the custom empty search state message + Livewire::test('pages::tickets.index') + ->set('search', 'TKT-NONEXISTENT-99') + ->assertSee('No Matching Tickets Found') + ->assertDontSee('No Support Tickets Found') + ->assertSee('matching your search query or filter criteria'); + + // Filter by Hold status: Should see the custom empty filter state message + Livewire::test('pages::tickets.index') + ->set('statusFilter', 'hold') + ->assertSee('No Matching Tickets Found') + ->assertDontSee('No Support Tickets Found') + ->assertSee('matching your search query or filter criteria'); +}); diff --git a/tests/Feature/TicketSystemTest.php b/tests/Feature/TicketSystemTest.php index 783f990..4a8c0ba 100644 --- a/tests/Feature/TicketSystemTest.php +++ b/tests/Feature/TicketSystemTest.php @@ -28,7 +28,8 @@ $this->get(route('tickets.index')) ->assertOk() - ->assertSee('Support Tickets'); + ->assertSee('Support Tickets') + ->assertSee('You do not currently have any active services expiring within 3 days'); }); test('submitting a support ticket creates the ticket and generates a unique ticket ID', function (): void { @@ -306,7 +307,7 @@ $service = app(TicketService::class); $tickets = $service->getListForUser($supportUser->id); - expect($tickets->contains('id', $ticket->id))->toBeTrue(); + expect(collect($tickets->all())->contains('id', $ticket->id))->toBeTrue(); }); test('commenting on a ticket notifies the creator and assigned user', function (): void {