new helpdesk module

This commit is contained in:
Mark Gregory
2026-06-12 10:56:36 +01:00
parent 67faed6165
commit 98c6ee4597
49 changed files with 2554 additions and 255 deletions
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\tests\Browser;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Tests\DuskTestCase;
final class HelpdeskBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
/** @test */
public function a_user_can_open_the_helpdesk_index_and_create_page(): void
{
Status::query()->create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']);
Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']);
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.index')
->waitFor('@helpdesk-ticket-index')
->assertSee('Helpdesk')
->clickLink('New ticket')
->waitFor('@helpdesk-ticket-create')
->assertSee('Create ticket');
});
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskKanbanBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
use DatabaseMigrations;
/** @test */
public function kanban_board_is_available_from_helpdesk_module(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['high']->id,
'subject' => 'Kanban browser ticket',
]);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.kanban')
->assertPresent('@helpdesk-kanban-board')
->assertSee('Kanban board')
->assertSee('Kanban browser ticket')
->assertPresent('@kanban-column-open');
});
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskNavigationBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_helpdesk_dashboard_and_main_pages_render_with_professional_headers(): void
{
$this->seedLookupData();
$user = $this->admin(['name' => 'Mark Gregory']);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.index')
->waitFor('@helpdesk-dashboard-widget')
->assertSee('Helpdesk dashboard')
->assertSee('Total tickets')
->assertSee('Open tickets')
->assertSee('Closed tickets')
->clickLink('Tickets')
->waitFor('@helpdesk-ticket-index')
->assertSee('Active tickets')
->clickLink('New ticket')
->waitFor('@helpdesk-ticket-create')
->assertSee('Create ticket')
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSee('Statuses')
->assertSee('Priorities')
->assertSee('Projects')
->assertSee('Workflow');
});
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskSettingsBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_settings_tabs_expose_lookup_git_and_workflow_setup_areas(): void
{
$this->seedLookupData();
$admin = $this->admin(['name' => 'Settings Admin']);
$this->browse(function (Browser $browser) use ($admin): void {
$browser->loginAs($admin)
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSee('Statuses')
->assertSee('Priorities')
->assertSee('Categories')
->assertSee('Teams')
->assertSee('Projects')
->assertSee('Git')
->assertSee('Workflow')
->press('Git')
->waitForText('Git repositories')
->assertSee('Remote URL')
->press('Workflow')
->waitForText('Workflow messages')
->assertSee('HTML email body');
});
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskTicketBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_user_can_create_and_view_ticket_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Ticket Browser User']);
$this->browse(function (Browser $browser) use ($user, $fixtures): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create')
->type('@ticket-subject', 'Browser created helpdesk ticket')
->script("document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').innerHTML = '<p>This browser ticket proves the form saves correctly.</p>'; document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));");
$browser->select('@ticket-priority', (string) $fixtures['high']->id)
->select('@ticket-category', (string) $fixtures['category']->id)
->select('@ticket-team', (string) $fixtures['team']->id)
->select('@ticket-project', (string) $fixtures['project']->id)
->press('@save-ticket')
->waitFor('@helpdesk-ticket-show')
->assertSee('Browser created helpdesk ticket')
->assertSee('High')
->assertSee('Support')
->assertSee('Ticket details');
});
}
public function test_user_can_add_nested_response_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Response Browser User']);
$ticket = $this->ticket(['owner' => $user, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Browser response ticket']);
$this->browse(function (Browser $browser) use ($user, $ticket): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.show', $ticket)
->waitFor('@helpdesk-ticket-show')
->script("document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').innerHTML = '<p>First browser response</p>'; document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));");
$browser->press('@post-comment')
->waitForText('First browser response')
->assertSee('First browser response');
});
}
}
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Concerns;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Ticket;
trait BuildsHelpdeskFixtures
{
protected function user(array $attributes = []): User
{
/** @var User $user */
$user = User::factory()->create(array_merge([
'name' => 'Helpdesk User',
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
], $attributes));
return $user;
}
protected function agent(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true])->save();
return $user;
}
protected function admin(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true, 'helpdesk_admin' => true])->save();
return $user;
}
protected function status(array $attributes = []): Status
{
return Status::query()->create(array_merge([
'name' => 'Open',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'sort_order' => 10,
'is_closed' => false,
], $attributes));
}
protected function closedStatus(array $attributes = []): Status
{
return $this->status(array_merge([
'name' => 'Closed',
'slug' => fake()->unique()->slug(2),
'color' => 'emerald',
'sort_order' => 99,
'is_closed' => true,
], $attributes));
}
protected function priority(array $attributes = []): Priority
{
return Priority::query()->create(array_merge([
'name' => 'Normal',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'sort_order' => 10,
], $attributes));
}
protected function category(array $attributes = []): Category
{
return Category::query()->create(array_merge([
'company_id' => null,
'name' => 'Support',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'description' => 'Support requests',
], $attributes));
}
protected function team(array $attributes = []): Team
{
return Team::query()->create(array_merge([
'company_id' => null,
'name' => 'Support Team',
'slug' => fake()->unique()->slug(2),
'color' => 'indigo',
'description' => 'Primary support team',
], $attributes));
}
protected function project(array $attributes = []): Project
{
return Project::query()->create(array_merge([
'company_id' => null,
'team_id' => null,
'title' => 'WMS Project',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'description' => 'Warehouse platform project',
], $attributes));
}
protected function repository(array $attributes = []): GitRepository
{
return GitRepository::query()->create(array_merge([
'company_id' => null,
'project_id' => null,
'name' => 'Core',
'provider' => 'git',
'remote_url' => 'git@example.com:wms/core.git',
'default_branch' => 'develop',
'webhook_secret' => 'test-secret',
'is_active' => true,
], $attributes));
}
protected function ticket(array $attributes = []): Ticket
{
$owner = $attributes['owner'] ?? $this->user(['name' => 'Requester']);
$agent = $attributes['agent'] ?? null;
unset($attributes['owner'], $attributes['agent']);
return Ticket::query()->create(array_merge([
'reference' => 'HD-' . now()->format('Y') . '-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT),
'user_id' => $owner->id,
'agent_id' => $agent?->id,
'status_id' => $this->status()->id,
'priority_id' => $this->priority()->id,
'category_id' => null,
'project_id' => null,
'team_id' => null,
'subject' => 'Test ticket subject',
'body' => '<p>Detailed helpdesk ticket body.</p>',
'last_activity_at' => now(),
], $attributes));
}
protected function seedLookupData(): array
{
$open = $this->status(['name' => 'Open', 'slug' => 'open']);
$closed = $this->closedStatus(['name' => 'Closed', 'slug' => 'closed']);
$normal = $this->priority(['name' => 'Normal', 'slug' => 'normal']);
$high = $this->priority(['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 1]);
$category = $this->category(['name' => 'Support', 'slug' => 'support']);
$team = $this->team(['name' => 'Support Team', 'slug' => 'support-team']);
$project = $this->project(['title' => 'WMS Core', 'slug' => 'wms-core', 'team_id' => $team->id]);
return compact('open', 'closed', 'normal', 'high', 'category', 'team', 'project');
}
protected function ensureCompanyTableHasOne(): ?int
{
if (! Schema::hasTable('companies')) {
return null;
}
$columns = Schema::getColumnListing('companies');
$payload = [];
foreach ($columns as $column) {
if (in_array($column, ['id', 'created_at', 'updated_at', 'deleted_at'], true)) {
continue;
}
if ($column === 'name') {
$payload[$column] = 'MPBA Test Company';
}
}
if (! array_key_exists('name', $payload) && in_array('company_name', $columns, true)) {
$payload['company_name'] = 'MPBA Test Company';
}
return (int) DB::table('companies')->insertGetId($payload);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\AgentAssignmentService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class AgentAssignmentTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_assignment_selects_least_loaded_agent(): void
{
$fixtures = $this->seedLookupData();
$busy = $this->agent(['name' => 'Busy Agent']);
$quiet = $this->agent(['name' => 'Quiet Agent']);
$this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$selected = app(AgentAssignmentService::class)->selectAgentId(null);
self::assertSame($quiet->id, $selected);
}
public function test_assignment_prefers_category_agents_when_category_has_agents(): void
{
$fixtures = $this->seedLookupData();
$generalAgent = $this->agent(['name' => 'General Agent']);
$categoryAgent = $this->agent(['name' => 'Category Agent']);
$fixtures['category']->agents()->attach($categoryAgent->id);
$this->ticket(['agent' => $generalAgent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$selected = app(AgentAssignmentService::class)->selectAgentId($fixtures['category']->id);
self::assertSame($categoryAgent->id, $selected);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Services\AttachmentService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class GitAndAttachmentTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_git_commit_can_be_tracked_against_repository_and_ticket(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository(['project_id' => $fixtures['project']->id]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
GitCommit::query()->create([
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('b', 40),
'short_hash' => 'bbbbbbb',
'branch' => 'develop',
'author_name' => 'Mark Gregory',
'author_email' => 'mark@example.com',
'message' => 'Resolve ' . $ticket->reference . ' ticket save redirect',
'url' => 'https://example.com/commit/b',
'committed_at' => now(),
]);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'short_hash' => 'bbbbbbb',
]);
self::assertSame($ticket->id, $repository->commits()->firstOrFail()->ticket_id);
}
public function test_attachment_service_stores_and_deletes_ticket_files(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->user();
$ticket = $this->ticket(['owner' => $user]);
$file = UploadedFile::fake()->create('evidence.pdf', 32, 'application/pdf');
app(AttachmentService::class)->storeMany($ticket, [$file], $user->id);
$attachment = Attachment::query()->where('ticket_id', $ticket->id)->firstOrFail();
Storage::disk('local')->assertExists($attachment->path);
self::assertSame('evidence.pdf', $attachment->original_name);
app(AttachmentService::class)->delete($attachment);
Storage::disk('local')->assertMissing($attachment->path);
$this->assertDatabaseMissing('helpdesk_attachments', ['id' => $attachment->id]);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class HelpdeskRouteTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_authenticated_user_can_open_dashboard_tickets_create_and_settings_pages(): void
{
$this->seedLookupData();
$user = $this->admin();
$this->actingAs($user)->get(route('helpdesk.index'))->assertOk()->assertSee('Helpdesk', false);
$this->actingAs($user)->get(route('helpdesk.tickets.index'))->assertOk()->assertSee('Active tickets', false);
$this->actingAs($user)->get(route('helpdesk.tickets.create'))->assertOk()->assertSee('Create ticket', false);
$this->actingAs($user)->get(route('helpdesk.settings'))->assertOk()->assertSee('Support configuration', false);
}
public function test_guest_is_redirected_from_helpdesk_pages_when_application_uses_auth_middleware(): void
{
$response = $this->get(route('helpdesk.tickets.index'));
self::assertTrue(in_array($response->getStatusCode(), [200, 302], true));
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Tests\TestCase;
final class HelpdeskTicketTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function a_user_can_create_a_ticket(): void
{
Status::query()->create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']);
Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']);
$user = User::factory()->create();
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Printer is not working')
->set('body', 'The office printer is showing an error code and will not print.')
->set('priority_id', Priority::query()->firstOrFail()->id)
->call('save')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_tickets', [
'subject' => 'Printer is not working',
'user_id' => $user->id,
]);
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\KanbanBoard;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class KanbanBoardTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
/** @test */
public function kanban_board_renders_visible_tickets_by_status(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['high']->id,
'subject' => 'Kanban visible ticket',
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->assertSee('Kanban board')
->assertSee($ticket->reference)
->assertSee('Kanban visible ticket')
->assertSee('High');
}
/** @test */
public function kanban_board_can_move_ticket_to_closed_status(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertHasNoErrors();
$ticket->refresh();
$this->assertSame($lookups['closed']->id, $ticket->status_id);
$this->assertNotNull($ticket->completed_at);
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\SettingsManager;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class SettingsCrudTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_settings_manager_creates_updates_and_deletes_statuses(): void
{
$admin = $this->admin();
$component = Livewire::actingAs($admin)
->test(SettingsManager::class)
->set('statusForm.name', 'Awaiting Customer')
->set('statusForm.color', 'amber')
->set('statusForm.sort_order', 20)
->set('statusForm.is_closed', false)
->call('saveStatus')
->assertHasNoErrors();
$status = Status::query()->where('slug', 'awaiting-customer')->firstOrFail();
$component->call('editStatus', $status->id)
->set('statusForm.name', 'Waiting Customer')
->call('saveStatus')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_statuses', ['id' => $status->id, 'name' => 'Waiting Customer']);
$component->call('deleteStatus', $status->id);
$this->assertDatabaseMissing('helpdesk_statuses', ['id' => $status->id]);
}
public function test_settings_manager_creates_lookup_project_team_repository_and_workflow_records(): void
{
$admin = $this->admin();
Livewire::actingAs($admin)
->test(SettingsManager::class)
->set('priorityForm.name', 'Critical')
->set('priorityForm.color', 'red')
->set('priorityForm.sort_order', 1)
->call('savePriority')
->set('categoryForm.name', 'Bug Fix')
->set('categoryForm.color', 'rose')
->set('categoryForm.description', 'Production defects')
->call('saveCategory')
->set('teamForm.name', 'Platform Support')
->set('teamForm.color', 'indigo')
->set('teamForm.description', 'Platform team')
->call('saveTeam')
->set('projectForm.title', 'Core WMS')
->set('projectForm.team_id', Team::query()->where('slug', 'platform-support')->firstOrFail()->id)
->set('projectForm.color', 'blue')
->set('projectForm.description', 'Core platform')
->call('saveProject')
->set('repositoryForm.name', 'WMS Core')
->set('repositoryForm.project_id', Project::query()->where('slug', 'core-wms')->firstOrFail()->id)
->set('repositoryForm.provider', 'github')
->set('repositoryForm.remote_url', 'git@github.com:mpba/wms-core.git')
->set('repositoryForm.default_branch', 'develop')
->set('repositoryForm.webhook_secret', 'secret')
->call('saveRepository')
->set('workflowForm.name', 'Ticket Raised')
->set('workflowForm.event', 'ticket.created')
->set('workflowForm.subject', 'Ticket {{ ticket.reference }} created')
->set('workflowForm.html_body', '<p>Hello {{ requester.name }}</p>')
->set('workflowForm.text_body', 'Hello {{ requester.name }}')
->set('workflowForm.to_role', 'requester')
->call('saveWorkflowTemplate')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_priorities', ['slug' => 'critical']);
$this->assertDatabaseHas('helpdesk_categories', ['slug' => 'bug-fix']);
$this->assertDatabaseHas('helpdesk_teams', ['slug' => 'platform-support']);
$this->assertDatabaseHas('helpdesk_projects', ['slug' => 'core-wms']);
$this->assertDatabaseHas('helpdesk_git_repositories', ['name' => 'WMS Core', 'provider' => 'github']);
$this->assertDatabaseHas('helpdesk_workflow_templates', ['name' => 'Ticket Raised', 'event' => 'ticket.created']);
}
public function test_settings_manager_can_edit_and_delete_all_new_setup_records(): void
{
$admin = $this->admin();
$priority = $this->priority(['name' => 'Low', 'slug' => 'low']);
$category = $this->category(['name' => 'Support', 'slug' => 'support']);
$team = $this->team(['name' => 'First Line', 'slug' => 'first-line']);
$project = $this->project(['title' => 'Portal', 'slug' => 'portal', 'team_id' => $team->id]);
$repository = $this->repository(['project_id' => $project->id, 'name' => 'Portal Repo']);
$template = WorkflowTemplate::query()->create([
'name' => 'Closed Message',
'event' => 'ticket.closed',
'subject' => 'Closed {{ ticket.reference }}',
'html_body' => '<p>Closed</p>',
'text_body' => 'Closed',
'to_role' => 'requester',
'is_active' => true,
]);
$component = Livewire::actingAs($admin)->test(SettingsManager::class);
$component->call('editPriority', $priority->id)->set('priorityForm.name', 'Low Priority')->call('savePriority');
$component->call('editCategory', $category->id)->set('categoryForm.name', 'Customer Support')->call('saveCategory');
$component->call('editTeam', $team->id)->set('teamForm.name', 'First Line Support')->call('saveTeam');
$component->call('editProject', $project->id)->set('projectForm.title', 'Customer Portal')->call('saveProject');
$component->call('editRepository', $repository->id)->set('repositoryForm.name', 'Customer Portal Repo')->call('saveRepository');
$component->call('editWorkflowTemplate', $template->id)->set('workflowForm.name', 'Ticket Closed Message')->call('saveWorkflowTemplate');
$this->assertDatabaseHas('helpdesk_priorities', ['id' => $priority->id, 'name' => 'Low Priority']);
$this->assertDatabaseHas('helpdesk_categories', ['id' => $category->id, 'name' => 'Customer Support']);
$this->assertDatabaseHas('helpdesk_teams', ['id' => $team->id, 'name' => 'First Line Support']);
$this->assertDatabaseHas('helpdesk_projects', ['id' => $project->id, 'title' => 'Customer Portal']);
$this->assertDatabaseHas('helpdesk_git_repositories', ['id' => $repository->id, 'name' => 'Customer Portal Repo']);
$this->assertDatabaseHas('helpdesk_workflow_templates', ['id' => $template->id, 'name' => 'Ticket Closed Message']);
$component->call('deletePriority', $priority->id)
->call('deleteCategory', $category->id)
->call('deleteRepository', $repository->id)
->call('deleteWorkflowTemplate', $template->id);
self::assertNull(Priority::query()->find($priority->id));
self::assertNull(Category::query()->find($category->id));
self::assertNull(GitRepository::query()->find($repository->id));
self::assertNull(WorkflowTemplate::query()->find($template->id));
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketConversationTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_owner_can_add_public_response_to_ticket(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($owner)
->test(TicketShow::class, ['ticket' => $ticket])
->set('commentBody', '<p>Please can you check this update?</p>')
->call('addComment')
->assertHasNoErrors()
->assertDispatched('helpdesk-comment-added');
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'user_id' => $owner->id,
'is_internal' => false,
]);
}
public function test_comment_can_have_a_nested_reply(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$comment = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $owner->id,
'body' => '<p>Parent response</p>',
'is_internal' => false,
]);
Livewire::actingAs($owner)
->test(TicketShow::class, ['ticket' => $ticket])
->call('startReply', $comment->id)
->set('replyBody', '<p>Nested reply body</p>')
->call('addReply')
->assertHasNoErrors()
->assertDispatched('helpdesk-reply-added');
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => $comment->id,
'body' => '<p>Nested reply body</p>',
]);
}
public function test_agent_can_update_status_to_closed_and_priority(): void
{
Mail::fake();
$fixtures = $this->seedLookupData();
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($agent)
->test(TicketShow::class, ['ticket' => $ticket])
->set('status_id', $fixtures['closed']->id)
->call('updateStatus')
->assertHasNoErrors()
->assertDispatched('helpdesk-status-updated')
->set('priority_id', $fixtures['high']->id)
->call('updatePriority')
->assertHasNoErrors()
->assertDispatched('helpdesk-priority-updated');
$ticket->refresh();
self::assertNotNull($ticket->completed_at);
self::assertSame($fixtures['closed']->id, $ticket->status_id);
self::assertSame($fixtures['high']->id, $ticket->priority_id);
}
public function test_unassigned_agent_cannot_update_another_users_ticket(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent_id' => null, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($agent)
->test(TicketShow::class, ['ticket' => $ticket])
->assertForbidden();
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketCrudTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_user_can_create_ticket_with_priority_category_team_and_project(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Mark Requester']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Warehouse printer does not print labels')
->set('body', '<p>The printer fails every time a dispatch label is generated.</p>')
->set('priority_id', $fixtures['high']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->call('save')
->assertHasNoErrors()
->assertRedirect();
$this->assertDatabaseHas('helpdesk_tickets', [
'user_id' => $user->id,
'subject' => 'Warehouse printer does not print labels',
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
]);
}
public function test_ticket_create_validates_required_fields(): void
{
$this->seedLookupData();
$user = $this->user();
Livewire::actingAs($user)
->test(TicketCreate::class)
->call('save')
->assertHasErrors(['subject' => 'required', 'body' => 'required', 'priority_id' => 'required']);
}
public function test_ticket_index_shows_only_visible_tickets_for_standard_user(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Owner User']);
$other = $this->user(['name' => 'Other User']);
$visible = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Visible ticket']);
$this->ticket(['owner' => $other, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Hidden ticket']);
Livewire::actingAs($owner)
->test(TicketIndex::class)
->assertSee($visible->reference)
->assertSee('Visible ticket')
->assertDontSee('Hidden ticket');
}
public function test_agent_sees_assigned_ticket_and_admin_sees_all_tickets(): void
{
$fixtures = $this->seedLookupData();
$agent = $this->agent(['name' => 'Assigned Agent']);
$admin = $this->admin(['name' => 'Admin User']);
$owner = $this->user();
$assigned = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Assigned ticket']);
$unassigned = $this->ticket(['owner' => $this->user(), 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Another ticket']);
Livewire::actingAs($agent)
->test(TicketIndex::class)
->assertSee($assigned->reference)
->assertDontSee($unassigned->reference);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->assertSee($assigned->reference)
->assertSee($unassigned->reference);
}
public function test_index_filters_by_search_priority_and_closed_scope(): void
{
$fixtures = $this->seedLookupData();
$admin = $this->admin();
$openTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['high']->id, 'subject' => 'API sync failure']);
$closedTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['closed']->id, 'priority_id' => $fixtures['normal']->id, 'completed_at' => now(), 'subject' => 'Old closed issue']);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->set('search', 'API sync')
->set('priority', (string) $fixtures['high']->id)
->assertSee($openTicket->reference)
->assertDontSee($closedTicket->reference);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->set('scope', 'closed')
->assertSee($closedTicket->reference)
->assertDontSee($openTicket->reference);
}
public function test_created_ticket_has_a_reference_and_last_activity_date(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user();
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Reference check')
->set('body', 'This ticket should have a generated reference.')
->set('priority_id', $fixtures['normal']->id)
->call('save');
$ticket = Ticket::query()->where('subject', 'Reference check')->firstOrFail();
self::assertStringStartsWith('HD-' . now()->format('Y') . '-', $ticket->reference);
self::assertNotNull($ticket->last_activity_at);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketPolicyTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_owner_can_view_and_comment_but_cannot_update_ticket(): void
{
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner]);
self::assertTrue(Gate::forUser($owner)->allows('view', $ticket));
self::assertTrue(Gate::forUser($owner)->allows('comment', $ticket));
self::assertFalse(Gate::forUser($owner)->allows('update', $ticket));
}
public function test_assigned_agent_can_view_comment_and_update_ticket(): void
{
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent' => $agent]);
self::assertTrue(Gate::forUser($agent)->allows('view', $ticket));
self::assertTrue(Gate::forUser($agent)->allows('comment', $ticket));
self::assertTrue(Gate::forUser($agent)->allows('update', $ticket));
}
public function test_helpdesk_admin_can_view_update_and_delete_any_ticket(): void
{
$admin = $this->admin();
$ticket = $this->ticket(['owner' => $this->user()]);
self::assertTrue(Gate::forUser($admin)->allows('view', $ticket));
self::assertTrue(Gate::forUser($admin)->allows('update', $ticket));
self::assertTrue(Gate::forUser($admin)->allows('delete', $ticket));
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Modules\Helpdesk\Services\WorkflowNotificationService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class WorkflowNotificationTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_workflow_dispatch_sends_active_template_and_logs_it(): void
{
Mail::fake();
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Requester Name', 'email' => 'requester@example.com']);
$agent = $this->agent(['name' => 'Agent Name', 'email' => 'agent@example.com']);
$ticket = $this->ticket([
'owner' => $owner,
'agent' => $agent,
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'project_id' => $fixtures['project']->id,
'team_id' => $fixtures['team']->id,
'subject' => 'Workflow ticket subject',
]);
WorkflowTemplate::query()->create([
'name' => 'Ticket created requester email',
'event' => 'ticket.created',
'subject' => 'Ticket {{ ticket.reference }} raised for {{ requester.name }}',
'html_body' => '<p>{{ ticket.subject }} / {{ ticket.priority }} / {{ ticket.status }}</p>',
'text_body' => '{{ ticket.subject }}',
'to_role' => 'requester',
'is_active' => true,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.created', $agent);
$this->assertDatabaseHas('helpdesk_workflow_logs', [
'ticket_id' => $ticket->id,
'event' => 'ticket.created',
'recipient' => 'requester@example.com',
'status' => 'sent',
'subject' => 'Ticket ' . $ticket->reference . ' raised for Requester Name',
]);
}
public function test_inactive_workflow_template_is_not_dispatched(): void
{
Mail::fake();
$ticket = $this->ticket();
WorkflowTemplate::query()->create([
'name' => 'Inactive template',
'event' => 'ticket.updated',
'subject' => 'Inactive',
'html_body' => '<p>Inactive</p>',
'text_body' => 'Inactive',
'to_role' => 'requester',
'is_active' => false,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner);
$this->assertDatabaseCount('helpdesk_workflow_logs', 0);
}
public function test_workflow_logs_skipped_when_recipient_is_missing(): void
{
$ticket = $this->ticket(['agent_id' => null]);
WorkflowTemplate::query()->create([
'name' => 'Agent template',
'event' => 'ticket.updated',
'subject' => 'Agent update',
'html_body' => '<p>Agent update</p>',
'text_body' => 'Agent update',
'to_role' => 'agent',
'is_active' => true,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner);
$this->assertDatabaseHas('helpdesk_workflow_logs', [
'ticket_id' => $ticket->id,
'event' => 'ticket.updated',
'recipient' => null,
'status' => 'skipped',
]);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class ModelRelationshipTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_ticket_loads_company_project_team_category_priority_status_and_git_commits(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository(['project_id' => $fixtures['project']->id]);
$ticket = $this->ticket([
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
]);
GitCommit::query()->create([
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('a', 40),
'short_hash' => 'aaaaaaa',
'branch' => 'develop',
'author_name' => 'Developer',
'author_email' => 'dev@example.com',
'message' => 'Fix HD reference handling',
'url' => 'https://example.com/commit/a',
'committed_at' => now(),
]);
$ticket->refresh()->load(['status', 'priority', 'category', 'team', 'project', 'gitCommits']);
self::assertSame('Open', $ticket->status?->name);
self::assertSame('High', $ticket->priority?->name);
self::assertSame('Support', $ticket->category?->name);
self::assertSame('Support Team', $ticket->team?->name);
self::assertSame('WMS Core', $ticket->project?->title);
self::assertCount(1, $ticket->gitCommits);
}
public function test_comments_support_nested_replies(): void
{
$ticket = $this->ticket();
$user = $this->user();
$comment = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'body' => '<p>Parent response</p>',
'is_internal' => false,
]);
Comment::query()->create([
'ticket_id' => $ticket->id,
'parent_id' => $comment->id,
'user_id' => $user->id,
'body' => '<p>Nested response</p>',
'is_internal' => false,
]);
self::assertSame('Nested response', strip_tags((string) $comment->replies()->firstOrFail()->body));
self::assertCount(1, $ticket->comments()->with('replies')->get());
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\TicketReferenceService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketReferenceServiceTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_it_generates_the_first_reference_for_the_current_year(): void
{
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00001', $reference);
}
public function test_it_generates_the_next_reference_after_existing_tickets(): void
{
$this->ticket(['reference' => 'HD-' . now()->format('Y') . '-00041']);
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00042', $reference);
}
}