phase 6 added helpdesk

This commit is contained in:
Mark Gregory
2026-07-24 11:28:06 +01:00
parent 5ecf20fe97
commit 8c74abfbc6
212 changed files with 23064 additions and 0 deletions
@@ -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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class DashboardTicketCardsSpellcheckFilesTest extends TestCase
{
#[Test]
public function dashboard_contains_browser_expected_kpi_labels(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/index.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('Total tickets', $view);
$this->assertStringContainsString('Open tickets', $view);
$this->assertStringContainsString('Closed tickets', $view);
}
#[Test]
public function ticket_right_cards_are_not_permanently_hidden(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringNotContainsString('display: none;', $view);
$this->assertStringNotContainsString('x-on:click.prevent', $view);
$this->assertStringNotContainsString('@click.prevent', $view);
}
#[Test]
public function helpdesk_editor_uses_native_spellcheck_contenteditable_area(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('contenteditable="true"', $view);
$this->assertStringContainsString('spellcheck="true"', $view);
$this->assertStringContainsString('lang="en-GB"', $view);
$this->assertStringContainsString('wire:model.defer', $view);
$this->assertStringNotContainsString('x-on:keyup', $view);
$this->assertStringNotContainsString('x-on:input', $view);
}
}
@@ -0,0 +1,134 @@
<?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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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_existing_ticket_supporting_files_route_stores_multiple_files(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->admin();
$ticket = $this->ticket(['owner' => $user]);
$this->actingAs($user)
->post(route('helpdesk.tickets.attachments.store', $ticket), [
'attachments' => [
UploadedFile::fake()->create('first-log.txt', 4, 'text/plain'),
UploadedFile::fake()->create('second-proof.pdf', 8, 'application/pdf'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'first-log.txt',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'second-proof.pdf',
]);
}
public function test_existing_ticket_supporting_files_route_accepts_source_code_attachments(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->admin();
$ticket = $this->ticket(['owner' => $user]);
$this->actingAs($user)
->post(route('helpdesk.tickets.attachments.store', $ticket), [
'attachments' => [
UploadedFile::fake()->create('TicketStoreController.php', 4, 'text/plain'),
UploadedFile::fake()->create('ticket-editor.js', 4, 'text/plain'),
UploadedFile::fake()->create('warehouse-sync.rpgle', 4, 'text/plain'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'TicketStoreController.php',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'ticket-editor.js',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'warehouse-sync.rpgle',
]);
}
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,95 @@
<?php
/**
* GitSpellcheckSettingsUiFilesTest.php
*
* Guards the Helpdesk Git webhook, ticket comment spellcheck and settings UI polish files.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class GitSpellcheckSettingsUiFilesTest extends TestCase
{
/**
* Ensure Git webhook ingestion accepts practical provider payload variants and flexible ticket references.
*/
public function test_git_webhook_ingestion_supports_common_provider_payload_variants(): void
{
$service = base_path('Modules/Helpdesk/Services/GitWebhookIngestionService.php');
$controller = base_path('Modules/Helpdesk/Http/Controllers/GitWebhookController.php');
$this->assertFileExists($service);
$this->assertFileExists($controller);
$serviceContents = file_get_contents($service) ?: '';
$controllerContents = file_get_contents($controller) ?: '';
self::assertStringContainsString('head_commit', $serviceContents);
self::assertStringContainsString('push.changes.0.commits', $serviceContents);
self::assertStringContainsString('HD[-\\s_]?', $serviceContents);
self::assertStringContainsString('sprintf(\'HD-%s-%05d\'', $serviceContents);
self::assertStringContainsString('hasValidBitbucketSignature', $controllerContents);
self::assertStringContainsString('hasValidGenericSignature', $controllerContents);
}
/**
* Ensure all ticket comment editors request native browser spellcheck assistance.
*/
public function test_ticket_comment_editors_enable_spellcheck_and_language_hints(): void
{
$ticketShow = base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php');
$commentThread = base_path('Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php');
$ticketShowContents = file_get_contents($ticketShow) ?: '';
$commentThreadContents = file_get_contents($commentThread) ?: '';
self::assertStringContainsString('contenteditable="true" role="textbox" aria-multiline="true" spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('name="commentBody" required spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('data-helpdesk-comment-subject', $ticketShowContents);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on"', $commentThreadContents);
self::assertStringContainsString('autocapitalize="sentences" inputmode="text"', $commentThreadContents);
}
/**
* Ensure settings sections use the polished Abbott-style card treatment.
*/
public function test_settings_sections_use_professional_card_treatment(): void
{
$settingsSection = base_path('Modules/Helpdesk/Resources/views/components/settings-section.blade.php');
$contents = file_get_contents($settingsSection) ?: '';
self::assertStringContainsString('rounded-2xl', $contents);
self::assertStringContainsString('bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900', $contents);
self::assertStringContainsString('Configuration', $contents);
self::assertStringContainsString("[&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-9", $contents);
self::assertStringContainsString('dark:bg-zinc-950/45', $contents);
}
/**
* Ensure settings lookup forms stay aligned and avoid oversized colour controls.
*/
public function test_lookup_settings_forms_use_compact_aligned_grid_layouts(): void
{
$settingsManager = base_path('Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php');
$colourSelect = base_path('Modules/Helpdesk/Resources/views/components/colour-select.blade.php');
$settingsContents = file_get_contents($settingsManager) ?: '';
$colourContents = file_get_contents($colourSelect) ?: '';
self::assertStringContainsString('grid gap-3 xl:grid-cols-12', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="statusForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="priorityForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="categoryForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="teamForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="projectForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="tagForm.color"', $settingsContents);
self::assertStringContainsString('grid grid-cols-[2.75rem_minmax(0,11rem)]', $colourContents);
self::assertStringContainsString('aria-label="Custom hex colour"', $colourContents);
self::assertStringContainsString('h-9 w-full rounded-lg', $colourContents);
self::assertStringNotContainsString('<flux:input', $colourContents);
}
}
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitWebhookLog;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class GitWebhookIngestionTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_signed_github_push_webhook_tracks_and_links_commit_to_ticket_reference(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'github',
'project_id' => $fixtures['project']->id,
'webhook_secret' => 'github-secret',
]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
$payload = [
'ref' => 'refs/heads/feature/' . $ticket->reference . '-automation',
'commits' => [
[
'id' => str_repeat('a', 40),
'message' => $ticket->reference . ' Add webhook ingestion',
'url' => 'https://github.example.test/org/repo/commit/' . str_repeat('a', 40),
'timestamp' => '2026-06-15T10:00:00+00:00',
'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITHUB_EVENT' => 'push',
'HTTP_X_GITHUB_DELIVERY' => 'delivery-123',
'HTTP_X_HUB_SIGNATURE_256' => 'sha256=' . hash_hmac('sha256', $json, 'github-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('a', 40),
'short_hash' => str_repeat('a', 12),
'branch' => 'feature/' . $ticket->reference . '-automation',
]);
$this->assertDatabaseHas('helpdesk_git_webhook_logs', [
'repository_id' => $repository->id,
'provider' => 'github',
'delivery_id' => 'delivery-123',
'event' => 'push',
'status' => 'accepted',
'commit_count' => 1,
'linked_count' => 1,
]);
}
public function test_github_webhook_rejects_invalid_signature_without_creating_commits(): void
{
$repository = $this->repository(['provider' => 'github', 'webhook_secret' => 'github-secret']);
$payload = ['ref' => 'refs/heads/develop', 'commits' => [['id' => str_repeat('b', 40), 'message' => 'No ticket']]];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITHUB_EVENT' => 'push',
'HTTP_X_HUB_SIGNATURE_256' => 'sha256=invalid',
], $json);
$response->assertForbidden()->assertJsonPath('status', 'rejected');
self::assertSame(0, GitCommit::query()->count());
self::assertSame('rejected', GitWebhookLog::query()->firstOrFail()->status);
}
public function test_github_non_push_webhook_is_audited_and_ignored(): void
{
$repository = $this->repository(['provider' => 'github', 'webhook_secret' => 'github-secret']);
$payload = ['zen' => 'Keep it logically awesome.'];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITHUB_EVENT' => 'ping',
'HTTP_X_HUB_SIGNATURE_256' => 'sha256=' . hash_hmac('sha256', $json, 'github-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'ignored');
self::assertSame(0, GitCommit::query()->count());
$this->assertDatabaseHas('helpdesk_git_webhook_logs', [
'repository_id' => $repository->id,
'provider' => 'github',
'event' => 'ping',
'status' => 'ignored',
'ignored_count' => 1,
]);
}
public function test_gitlab_push_webhook_tracks_and_links_commit_to_ticket_reference(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'gitlab',
'project_id' => $fixtures['project']->id,
'webhook_secret' => 'gitlab-secret',
]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
$payload = [
'ref' => 'refs/heads/develop',
'commits' => [
[
'id' => str_repeat('c', 40),
'message' => 'Fix ' . $ticket->reference . ' SLA report',
'url' => 'https://gitlab.example.test/org/repo/-/commit/' . str_repeat('c', 40),
'timestamp' => '2026-06-15T11:00:00+00:00',
'author' => ['name' => 'Support Agent', 'email' => 'agent@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITLAB_EVENT' => 'Push Hook',
'HTTP_X_GITLAB_TOKEN' => 'gitlab-secret',
'HTTP_X_GITLAB_EVENT_UUID' => 'gitlab-delivery-123',
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('c', 40),
'branch' => 'develop',
]);
$this->assertDatabaseHas('helpdesk_git_webhook_logs', [
'repository_id' => $repository->id,
'provider' => 'gitlab',
'delivery_id' => 'gitlab-delivery-123',
'event' => 'Push Hook',
'status' => 'accepted',
'linked_count' => 1,
]);
}
public function test_gitea_local_webhook_tracks_latest_push_without_repository_id(): void
{
config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l');
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'gitea',
'project_id' => $fixtures['project']->id,
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'name' => 'WMS-Core',
'full_name' => 'Admin/WMS-Core',
'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'html_url' => 'http://git.vm:3000/Admin/WMS-Core',
],
'commits' => [
[
'id' => str_repeat('d', 40),
'message' => $ticket->reference . ' Fix local Gitea webhook ingestion',
'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('d', 40),
'timestamp' => '2026-06-24T14:20:00+00:00',
'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic YWxhZGRpbjpvcGVuc2VzYW1l',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_DELIVERY' => 'gitea-delivery-123',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('d', 40),
'branch' => 'main',
]);
$this->assertDatabaseHas('helpdesk_git_webhook_logs', [
'repository_id' => $repository->id,
'provider' => 'gitea',
'delivery_id' => 'gitea-delivery-123',
'event' => 'push',
'status' => 'accepted',
'linked_count' => 1,
]);
}
public function test_gitea_local_webhook_rejects_invalid_signature_and_authorization_without_creating_commits(): void
{
config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l');
$this->repository([
'provider' => 'gitea',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'],
'commits' => [['id' => str_repeat('e', 40), 'message' => 'No auth']],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic invalid',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'wrong-secret'),
], $json);
$response->assertForbidden()->assertJsonPath('status', 'rejected');
self::assertSame(0, GitCommit::query()->count());
}
public function test_gitea_local_webhook_accepts_valid_signature_when_optional_authorization_header_differs(): void
{
config()->set('services.gitea.webhook_authorization', 'expected-basic-token');
$repository = $this->repository([
'provider' => 'gitea',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'],
'commits' => [['id' => str_repeat('b', 40), 'message' => 'Valid signed Gitea push']],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic something-different-from-gitea-ui',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'hash' => str_repeat('b', 40),
]);
}
public function test_gitea_local_webhook_accepts_prefixed_signature_and_case_insensitive_provider(): void
{
config()->set('services.gitea.webhook_authorization', null);
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'Gitea',
'project_id' => $fixtures['project']->id,
'name' => 'Local',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'name' => 'WMS-Core',
'full_name' => 'Admin/WMS-Core',
'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'html_url' => 'http://git.vm:3000/Admin/WMS-Core',
],
'commits' => [
[
'id' => str_repeat('f', 40),
'message' => 'Local Gitea push should be visible on the dashboard',
'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('f', 40),
'timestamp' => '2026-06-24T15:00:00+00:00',
'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => 'sha256=' . hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'hash' => str_repeat('f', 40),
'branch' => 'main',
]);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class HelpdeskLivewireAssetsTest extends TestCase
{
/**
* Ensure Helpdesk Livewire pages can process browser clicks and form saves.
*/
public function test_helpdesk_sidebar_layout_loads_livewire_scripts_before_flux_scripts(): void
{
$layout = file_get_contents(base_path('Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php'));
self::assertIsString($layout);
self::assertStringContainsString('@livewireScripts', $layout);
self::assertStringContainsString('@fluxScripts', $layout);
self::assertLessThan(
strpos($layout, '@fluxScripts'),
strpos($layout, '@livewireScripts'),
'Livewire scripts must be loaded before Flux scripts so settings CRUD actions fire in the browser.',
);
}
}
@@ -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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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));
}
}
@@ -0,0 +1,208 @@
<?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\Models\KanbanBoard as KanbanBoardModel;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class KanbanBoardTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_helpdesk_kanban_board_page_renders(): void
{
$this->seedLookupData();
$user = $this->user(['name' => 'Kanban Test User']);
$this->actingAs($user)
->get(route('helpdesk.kanban'))
->assertOk();
}
/** @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,
'board_id' => $lookups['board']->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 test_ticket_can_be_moved_to_another_board(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$boardA = $lookups['board'];
$boardB = KanbanBoardModel::query()->create([
'name' => 'Regression Target Board',
'slug' => 'regression-target-board',
'is_default' => false,
'sort_order' => 50,
]);
$boardB->statuses()->sync([
$lookups['open']->id => ['sort_order' => 10],
]);
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $boardA->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->call('moveTicketToBoard', $ticket->id, $boardB->id)
->assertHasNoErrors();
$ticket->refresh();
$this->assertSame($boardB->id, $ticket->board_id);
$this->assertSame($lookups['open']->id, $ticket->status_id);
}
/** @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,
'board_id' => $lookups['board']->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->assertSame($lookups['board']->id, $ticket->board_id);
$this->assertNotNull($ticket->completed_at);
}
/** @test */
public function kanban_ticket_can_be_moved_to_another_board_and_only_has_one_board(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$targetBoard = KanbanBoardModel::query()->create([
'name' => 'Development Board',
'slug' => 'development-board',
'is_default' => false,
'sort_order' => 20,
]);
$targetBoard->statuses()->sync([$lookups['open']->id => ['sort_order' => 10]]);
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $lookups['board']->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->call('moveTicketToBoard', $ticket->id, $targetBoard->id)
->assertHasNoErrors();
$ticket->refresh();
$this->assertSame($targetBoard->id, $ticket->board_id);
}
/** @test */
public function kanban_board_filters_tickets_to_the_selected_board_only(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$otherBoard = KanbanBoardModel::query()->create([
'name' => 'Escalation Board',
'slug' => 'escalation-board',
'is_default' => false,
'sort_order' => 30,
]);
$otherBoard->statuses()->sync([$lookups['open']->id => ['sort_order' => 10]]);
$this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $lookups['board']->id,
'subject' => 'Default board ticket',
]);
$this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $otherBoard->id,
'subject' => 'Escalation board ticket',
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->set('board_id', $lookups['board']->id)
->assertSee('Default board ticket')
->assertDontSee('Escalation board ticket')
->set('board_id', $otherBoard->id)
->assertSee('Escalation board ticket')
->assertDontSee('Default board ticket');
}
/** @test */
public function kanban_board_increments_refresh_nonce_after_ticket_move(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $lookups['board']->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->assertSet('refreshNonce', 0)
->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertSet('refreshNonce', 1)
->assertHasNoErrors();
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\AutomationRule;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\ChecklistItem;
use Modules\Helpdesk\Models\ChecklistTemplate;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\SlaPolicy;
use Modules\Helpdesk\Models\TicketSlaEvent;
use Modules\Helpdesk\Services\ChecklistService;
use Modules\Helpdesk\Services\SlaService;
use Modules\Helpdesk\Services\TicketService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class RedmineInspiredHelpdeskFeatureTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_ticket_creation_applies_automation_sla_checklist_and_product_linkage(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Mark Requester']);
$product = Product::query()->create(['name' => 'WMS Platform', 'slug' => 'wms-platform', 'type' => 'service', 'is_active' => true]);
SlaPolicy::query()->create([
'name' => 'High Priority SLA',
'slug' => 'high-priority-sla',
'priority_id' => $fixtures['high']->id,
'first_response_minutes' => 30,
'resolution_minutes' => 240,
'is_default' => true,
'is_active' => true,
]);
AutomationRule::query()->create([
'name' => 'Printer escalation',
'slug' => 'printer-escalation',
'match_subject' => 'printer',
'set_priority_id' => $fixtures['high']->id,
'set_team_id' => $fixtures['team']->id,
'is_active' => true,
'sort_order' => 1,
]);
ChecklistTemplate::query()->create([
'name' => 'Bug checklist',
'slug' => 'bug-checklist',
'category_id' => $fixtures['category']->id,
'items' => ['Confirm reproduction', 'Record workaround'],
'is_active' => true,
]);
$ticket = app(TicketService::class)->create($user, [
'subject' => 'Printer labels are failing',
'body' => '<p>Printer labels fail for every order.</p>',
'priority_id' => $fixtures['normal']->id,
'category_id' => $fixtures['category']->id,
'project_id' => $fixtures['project']->id,
'product_id' => $product->id,
]);
$ticket = $ticket->fresh(['checklistItems']);
self::assertSame($fixtures['high']->id, $ticket->priority_id);
self::assertSame($fixtures['team']->id, $ticket->team_id);
self::assertSame($product->id, $ticket->product_id);
self::assertNotNull($ticket->sla_policy_id);
self::assertNotNull($ticket->sla_first_response_due_at);
self::assertCount(2, $ticket->checklistItems);
$this->assertDatabaseHas('helpdesk_ticket_sla_events', ['ticket_id' => $ticket->id, 'event' => 'sla.policy_applied']);
}
public function test_canned_response_macros_insert_into_ticket_reply_editor(): void
{
$fixtures = $this->seedLookupData();
$agent = $this->agent(['name' => 'Agent Smith']);
$ticket = $this->ticket(['owner' => $this->user(['name' => 'Customer Jane']), 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$response = CannedResponse::query()->create([
'name' => 'Acknowledgement',
'slug' => 'acknowledgement',
'body' => 'Hello {{ requester.name }}, ticket {{ ticket.reference }} is being reviewed by {{ agent.name }}.',
'is_active' => true,
]);
Livewire::actingAs($agent)
->test(TicketShow::class, ['ticket' => $ticket])
->set('canned_response_id', $response->id)
->call('applyCannedResponse')
->assertSet('commentBody', 'Hello Customer Jane, ticket ' . $ticket->reference . ' is being reviewed by Agent Smith.');
}
public function test_sla_service_records_first_response_and_resolution_breaches(): void
{
$fixtures = $this->seedLookupData();
$agent = $this->agent();
$ticket = $this->ticket(['status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['high']->id]);
$ticket->forceFill([
'sla_first_response_due_at' => now()->subMinute(),
'sla_resolution_due_at' => now()->subMinute(),
])->save();
app(SlaService::class)->recordPublicResponse($ticket->fresh(), $agent);
app(SlaService::class)->recordClosure($ticket->fresh());
$ticket = $ticket->fresh();
self::assertNotNull($ticket->first_response_at);
self::assertNotNull($ticket->sla_first_response_breached_at);
self::assertNotNull($ticket->sla_resolution_breached_at);
self::assertGreaterThanOrEqual(2, TicketSlaEvent::query()->where('ticket_id', $ticket->id)->count());
}
public function test_checklist_items_can_be_toggled_by_agents(): void
{
$fixtures = $this->seedLookupData();
$agent = $this->agent();
$ticket = $this->ticket(['agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$item = ChecklistItem::query()->create(['ticket_id' => $ticket->id, 'title' => 'Confirm reproduction', 'sort_order' => 10]);
$updated = app(ChecklistService::class)->toggle($item, $agent);
self::assertTrue($updated->is_completed);
self::assertSame($agent->id, $updated->completed_by);
self::assertNotNull($updated->completed_at);
}
}
@@ -0,0 +1,269 @@
<?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\AutomationRule;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\ChecklistTemplate;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\SlaPolicy;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Tag;
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 BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_all_helpdesk_settings_create_and_update_actions_persist_records(): void
{
$fixtures = $this->seedLookupData();
$admin = $this->admin(['name' => 'Settings CRUD Admin']);
$this->actingAs($admin);
Livewire::test(SettingsManager::class)
->set('statusForm.name', 'Awaiting Customer')
->set('statusForm.slug', 'awaiting-customer')
->set('statusForm.color', 'amber')
->set('statusForm.sort_order', 20)
->call('saveStatus')
->assertHasNoErrors();
$status = Status::query()->where('slug', 'awaiting-customer')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editStatus', $status->id)
->set('statusForm.name', 'Awaiting Customer Reply')
->call('saveStatus')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_statuses', ['id' => $status->id, 'name' => 'Awaiting Customer Reply']);
Livewire::test(SettingsManager::class)
->set('priorityForm.name', 'Critical')
->set('priorityForm.slug', 'critical')
->set('priorityForm.color', 'red')
->set('priorityForm.sort_order', 1)
->call('savePriority')
->assertHasNoErrors();
$priority = Priority::query()->where('slug', 'critical')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editPriority', $priority->id)
->set('priorityForm.name', 'Critical Updated')
->call('savePriority')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_priorities', ['id' => $priority->id, 'name' => 'Critical Updated']);
Livewire::test(SettingsManager::class)
->set('categoryForm.name', 'Billing')
->set('categoryForm.slug', 'billing')
->set('categoryForm.color', 'blue')
->call('saveCategory')
->assertHasNoErrors();
$category = Category::query()->where('slug', 'billing')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editCategory', $category->id)
->set('categoryForm.name', 'Billing Updated')
->call('saveCategory')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_categories', ['id' => $category->id, 'name' => 'Billing Updated']);
Livewire::test(SettingsManager::class)
->set('teamForm.name', 'Second Line')
->set('teamForm.slug', 'second-line')
->set('teamForm.color', 'indigo')
->call('saveTeam')
->assertHasNoErrors();
$team = Team::query()->where('slug', 'second-line')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editTeam', $team->id)
->set('teamForm.name', 'Second Line Updated')
->call('saveTeam')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_teams', ['id' => $team->id, 'name' => 'Second Line Updated']);
Livewire::test(SettingsManager::class)
->set('projectForm.team_id', $fixtures['team']->id)
->set('projectForm.title', 'Customer Portal')
->set('projectForm.slug', 'customer-portal')
->set('projectForm.color', 'blue')
->call('saveProject')
->assertHasNoErrors();
$project = Project::query()->where('slug', 'customer-portal')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editProject', $project->id)
->set('projectForm.title', 'Customer Portal Updated')
->call('saveProject')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_projects', ['id' => $project->id, 'title' => 'Customer Portal Updated']);
Livewire::test(SettingsManager::class)
->set('boardForm.name', 'Escalations')
->set('boardForm.slug', 'escalations')
->set('boardForm.sort_order', 30)
->set('boardForm.status_ids', [$fixtures['open']->id, $fixtures['closed']->id])
->call('saveBoard')
->assertHasNoErrors();
$board = KanbanBoard::query()->where('slug', 'escalations')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editBoard', $board->id)
->set('boardForm.name', 'Escalations Updated')
->call('saveBoard')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_kanban_boards', ['id' => $board->id, 'name' => 'Escalations Updated']);
Livewire::test(SettingsManager::class)
->set('repositoryForm.project_id', $fixtures['project']->id)
->set('repositoryForm.name', 'WMS Core Repo')
->set('repositoryForm.provider', 'github')
->set('repositoryForm.remote_url', 'git@example.com:wms/core.git')
->set('repositoryForm.default_branch', 'main')
->call('saveRepository')
->assertHasNoErrors();
$repository = GitRepository::query()->where('name', 'WMS Core Repo')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editRepository', $repository->id)
->set('repositoryForm.default_branch', 'develop')
->call('saveRepository')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_git_repositories', ['id' => $repository->id, 'default_branch' => 'develop']);
Livewire::test(SettingsManager::class)
->set('workflowForm.name', 'Ticket Created')
->set('workflowForm.event', 'ticket.created')
->set('workflowForm.subject', 'New ticket')
->set('workflowForm.html_body', '<p>Created</p>')
->set('workflowForm.to_role', 'requester')
->call('saveWorkflowTemplate')
->assertHasNoErrors();
$workflow = WorkflowTemplate::query()->where('name', 'Ticket Created')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editWorkflowTemplate', $workflow->id)
->set('workflowForm.subject', 'New ticket updated')
->call('saveWorkflowTemplate')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_workflow_templates', ['id' => $workflow->id, 'subject' => 'New ticket updated']);
Livewire::test(SettingsManager::class)
->set('cannedForm.category_id', $fixtures['category']->id)
->set('cannedForm.team_id', $fixtures['team']->id)
->set('cannedForm.name', 'Greeting')
->set('cannedForm.slug', 'greeting')
->set('cannedForm.body', '<p>Hello</p>')
->call('saveCannedResponse')
->assertHasNoErrors();
$canned = CannedResponse::query()->where('slug', 'greeting')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editCannedResponse', $canned->id)
->set('cannedForm.body', '<p>Hello updated</p>')
->call('saveCannedResponse')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_canned_responses', ['id' => $canned->id, 'body' => '<p>Hello updated</p>']);
Livewire::test(SettingsManager::class)
->set('slaForm.priority_id', $fixtures['normal']->id)
->set('slaForm.category_id', $fixtures['category']->id)
->set('slaForm.name', 'Standard SLA')
->set('slaForm.slug', 'standard-sla')
->set('slaForm.first_response_minutes', 120)
->set('slaForm.resolution_minutes', 1440)
->call('saveSlaPolicy')
->assertHasNoErrors();
$sla = SlaPolicy::query()->where('slug', 'standard-sla')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editSlaPolicy', $sla->id)
->set('slaForm.resolution_minutes', 720)
->call('saveSlaPolicy')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_sla_policies', ['id' => $sla->id, 'resolution_minutes' => 720]);
Livewire::test(SettingsManager::class)
->set('automationForm.name', 'Auto Route')
->set('automationForm.slug', 'auto-route')
->set('automationForm.set_priority_id', $fixtures['high']->id)
->set('automationForm.set_status_id', $fixtures['open']->id)
->call('saveAutomationRule')
->assertHasNoErrors();
$automation = AutomationRule::query()->where('slug', 'auto-route')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editAutomationRule', $automation->id)
->set('automationForm.match_subject', 'urgent')
->call('saveAutomationRule')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_automation_rules', ['id' => $automation->id, 'match_subject' => 'urgent']);
Livewire::test(SettingsManager::class)
->set('checklistTemplateForm.category_id', $fixtures['category']->id)
->set('checklistTemplateForm.project_id', $fixtures['project']->id)
->set('checklistTemplateForm.name', 'Release Checklist')
->set('checklistTemplateForm.slug', 'release-checklist')
->set('checklistTemplateForm.items_text', "Check logs\nConfirm release")
->call('saveChecklistTemplate')
->assertHasNoErrors();
$checklist = ChecklistTemplate::query()->where('slug', 'release-checklist')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editChecklistTemplate', $checklist->id)
->set('checklistTemplateForm.items_text', 'Updated item')
->call('saveChecklistTemplate')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_checklist_templates', ['id' => $checklist->id]);
self::assertSame(['Updated item'], $checklist->refresh()->items);
Livewire::test(SettingsManager::class)
->set('tagForm.name', 'VIP')
->set('tagForm.slug', 'vip')
->set('tagForm.color', 'purple')
->call('saveTag')
->assertHasNoErrors();
$tag = Tag::query()->where('slug', 'vip')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editTag', $tag->id)
->set('tagForm.color', 'purple')
->call('saveTag')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_tags', ['id' => $tag->id, 'color' => 'purple']);
Livewire::test(SettingsManager::class)
->set('productForm.name', 'WMS')
->set('productForm.slug', 'wms')
->set('productForm.type', 'service')
->call('saveProduct')
->assertHasNoErrors();
$product = Product::query()->where('slug', 'wms')->firstOrFail();
Livewire::test(SettingsManager::class)
->call('editProduct', $product->id)
->set('productForm.version', '1.1')
->call('saveProduct')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_products', ['id' => $product->id, 'version' => '1.1']);
}
public function test_helpdesk_colour_selectors_are_dropdowns_and_reject_unknown_values(): void
{
$this->seedLookupData();
$admin = $this->admin(['name' => 'Colour Settings Admin']);
$this->actingAs($admin);
Livewire::test(SettingsManager::class)
->assertSee('● Red — #dc2626')
->assertSee('● Green — #16a34a')
->set('statusForm.name', 'Invalid Colour')
->set('statusForm.slug', 'invalid-colour')
->set('statusForm.color', 'brown')
->set('statusForm.sort_order', 99)
->call('saveStatus')
->assertHasErrors(['statusForm.color']);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketAttachmentExperienceFilesTest extends TestCase
{
/** @test */
public function create_ticket_view_has_drag_drop_upload_progress_and_paste_attachment_wiring(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
self::assertIsString($view);
self::assertStringContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringContainsString('livewire-upload-progress', $view);
self::assertStringContainsString('DataTransfer', $view);
self::assertStringContainsString('helpdesk-editor-files-pasted', $view);
self::assertIsString($editor);
self::assertStringContainsString('insertCodeBlock', $editor);
self::assertStringContainsString('handlePaste', $editor);
self::assertStringContainsString('spellcheck="true"', $editor);
}
/** @test */
public function attachment_preview_routes_controllers_and_metadata_are_present(): void
{
$routes = file_get_contents(base_path('Modules/Helpdesk/Routes/web.php'));
$model = file_get_contents(base_path('Modules/Helpdesk/Models/Attachment.php'));
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$migration = file_get_contents(base_path('Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php'));
$sourcePreview = base_path('Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php');
self::assertIsString($routes);
self::assertStringContainsString('attachments.preview', $routes);
self::assertStringContainsString('attachments.destroy', $routes);
self::assertStringNotContainsString('AttachmentPreviewController::class', $routes);
self::assertStringNotContainsString('AttachmentDeleteController::class', $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'preview']", $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'destroy']", $routes);
self::assertFileExists($sourcePreview);
self::assertStringContainsString('Read-only source viewer', (string) file_get_contents($sourcePreview));
self::assertIsString($model);
self::assertStringContainsString('function isSourceCode', $model);
self::assertStringContainsString('function canPreview', $model);
self::assertStringContainsString('function language', $model);
self::assertStringContainsString('function loadedAttribute', $model);
self::assertStringContainsString('function displayName', $model);
self::assertStringContainsString('function byteSize', $model);
self::assertStringContainsString('function downloadCount', $model);
self::assertStringContainsString('function isDeletedAttachment', $model);
self::assertStringNotContainsString('$this->extension ?: pathinfo', $model);
self::assertStringNotContainsString('is_string($this->category)', $model);
self::assertIsString($service);
self::assertStringContainsString('checksum_sha256', $service);
self::assertStringContainsString('function softDelete', $service);
self::assertIsString($migration);
self::assertStringContainsString('download_count', $migration);
self::assertStringContainsString('deleted_at', $migration);
}
}
@@ -0,0 +1,84 @@
<?php
/**
* TicketBranchCommandUiFilesTest.php
*
* Guards the ticket-level Git activity branch command builder and comment UI polish.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketBranchCommandUiFilesTest extends TestCase
{
/**
* Ensure ticket Git activity exposes a professional branch command copy workflow.
*/
public function test_ticket_git_activity_has_branch_command_builder_and_copy_button(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-branch-command', $view);
self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view);
self::assertStringContainsString('data-helpdesk-branch-suffix', $view);
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
self::assertStringContainsString('feature/', $view);
self::assertStringContainsString('bugfix/', $view);
self::assertStringContainsString('hotfix/', $view);
self::assertStringContainsString('support/', $view);
self::assertStringContainsString('data-helpdesk-copy-branch-command', $view);
self::assertStringContainsString('copyHelpdeskBranchCommand', $view);
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
self::assertStringContainsString('Copied to clipboard.', $view);
}
/**
* Ensure visibility selectors keep their accent backgrounds during interaction.
*/
public function test_visibility_controls_keep_accent_backgrounds_on_hover_and_focus(): void
{
$ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php';
$ticketView = file_get_contents($ticketViewPath);
$threadView = file_get_contents($threadViewPath);
self::assertIsString($ticketView);
self::assertIsString($threadView);
self::assertStringContainsString('hover:bg-emerald-100/80', $ticketView);
self::assertStringContainsString('focus-within:bg-emerald-100/80', $ticketView);
self::assertStringContainsString('hover:bg-purple-100/80', $ticketView);
self::assertStringContainsString('focus-within:bg-purple-100/80', $ticketView);
self::assertStringContainsString('hover:bg-emerald-100/80', $threadView);
self::assertStringContainsString('hover:bg-purple-100/80', $threadView);
}
/**
* Ensure ticket comments and threaded replies expose browser spelling hints.
*/
public function test_ticket_comment_subjects_and_bodies_enable_spellcheck(): void
{
$ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php';
$ticketView = file_get_contents($ticketViewPath);
$threadView = file_get_contents($threadViewPath);
self::assertIsString($ticketView);
self::assertIsString($threadView);
self::assertStringContainsString('data-helpdesk-comment-subject', $ticketView);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $ticketView);
self::assertStringContainsString('role="textbox" aria-multiline="true" spellcheck="true"', $ticketView);
self::assertStringContainsString('id="reply-subject-{{ $comment->id }}"', $threadView);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $threadView);
}
}
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Modules\Company\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Services\TicketService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketConversationTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
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_comment_post_endpoint_creates_lotus_identity_and_search_text(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'subject' => 'Searchable response subject',
'commentBody' => '<p><strong>Searchable response</strong></p>',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$comment = Comment::query()->where('ticket_id', $ticket->id)->firstOrFail();
self::assertNotEmpty($comment->guid);
self::assertNotEmpty($comment->unid);
self::assertSame($comment->unid, $comment->root_unid);
self::assertSame('Searchable response subject', $comment->subject);
self::assertSame('Searchable response', $comment->body_text);
self::assertSame(1, $comment->sync_sequence);
}
public function test_nested_reply_post_endpoint_preserves_lotus_parent_identity(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$parent = app(TicketService::class)->comment($ticket, $owner, '<p>Parent response</p>');
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'parent_id' => $parent->id,
'subject' => 'RE: Parent response',
'commentBody' => '<p>Child response</p>',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$reply = Comment::query()->where('parent_id', $parent->id)->firstOrFail();
self::assertSame($parent->unid, $reply->parent_unid);
self::assertSame($parent->root_unid, $reply->root_unid);
self::assertSame('RE: Parent response', $reply->subject);
}
public function test_existing_ticket_accepts_multiple_supporting_file_attachments(): void
{
Storage::fake((string) config('helpdesk.disk', 'public'));
$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('supportingFiles', [
UploadedFile::fake()->create('existing-ticket-log.txt', 8, 'text/plain'),
UploadedFile::fake()->create('existing-ticket-proof.pdf', 12, 'application/pdf'),
])
->call('saveSupportingFiles')
->assertHasNoErrors()
->assertDispatched('helpdesk-supporting-files-added');
self::assertSame(2, Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count());
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'existing-ticket-log.txt',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'existing-ticket-proof.pdf',
]);
}
public function test_post_endpoint_accepts_multiple_comment_attachments(): void
{
Storage::fake((string) config('helpdesk.disk', 'public'));
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'subject' => 'Attachments attached',
'commentBody' => '<p>Files attached.</p>',
'attachments' => [
UploadedFile::fake()->create('first-log.txt', 8, 'text/plain'),
UploadedFile::fake()->create('second-log.txt', 8, 'text/plain'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$comment = Comment::query()->where('ticket_id', $ticket->id)->firstOrFail();
self::assertSame(2, Attachment::query()->where('comment_id', $comment->id)->count());
}
public function test_mpba_platform_admin_can_delete_comment(): void
{
$fixtures = $this->seedLookupData();
$company = Company::query()->create([
'name' => 'MPBA Limited',
'slug' => 'mpba-limited',
'status' => 'active',
'timezone' => 'Europe/London',
'is_system_company' => true,
]);
$admin = $this->user(['company_id' => $company->id, 'is_platform_admin' => true]);
$owner = $this->user(['company_id' => $company->id]);
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$comment = app(TicketService::class)->comment($ticket, $owner, '<p>Delete me</p>');
$this->actingAs($admin)
->delete(route('helpdesk.tickets.comments.destroy', [$ticket, $comment]))
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$comment->refresh();
self::assertNotNull($comment->tombstoned_at);
}
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)
->set('closureComment', 'Closing after agent review.')
->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_closing_ticket_requires_a_closure_comment(): void
{
$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,
]);
$this->actingAs($agent)
->from(route('helpdesk.tickets.show', $ticket))
->post(route('helpdesk.tickets.status.update', $ticket), [
'status_id' => $fixtures['closed']->id,
])
->assertRedirect(route('helpdesk.tickets.show', $ticket))
->assertSessionHasErrors('closure_comment');
$ticket->refresh();
self::assertSame($fixtures['open']->id, $ticket->status_id);
self::assertNull($ticket->completed_at);
}
public function test_closing_ticket_creates_a_closure_comment(): void
{
$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,
]);
$this->actingAs($agent)
->post(route('helpdesk.tickets.status.update', $ticket), [
'status_id' => $fixtures['closed']->id,
'closure_comment' => 'Issue resolved and verified before closure.',
'closure_internal' => '0',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$ticket->refresh();
self::assertSame($fixtures['closed']->id, $ticket->status_id);
self::assertNotNull($ticket->completed_at);
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'user_id' => $agent->id,
'subject' => 'Closure comment',
'body' => 'Issue resolved and verified before closure.',
'is_internal' => false,
]);
}
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,45 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateAttachmentBrowserSafetyTest extends TestCase
{
public function test_ticket_create_uses_standard_livewire_temporary_uploads(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('wire:submit.prevent="save"', $view);
self::assertStringContainsString('type="file"', $view);
self::assertStringContainsString('wire:model="attachments"', $view);
self::assertStringContainsString('multiple', $view);
self::assertStringContainsString('wire:key="ticket-create-attachments-input"', $view);
}
public function test_ticket_create_does_not_mix_existing_ticket_upload_behaviour_into_create_form(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringNotContainsString('@vite([\'resources/css/app.css\', \'resources/js/app.js\'])', $view);
self::assertStringNotContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringNotContainsString('DataTransfer', $view);
self::assertStringNotContainsString('$wire.uploadMultiple', $view);
self::assertStringNotContainsString('input.files =', $view);
self::assertStringNotContainsString('helpdesk-editor-files-pasted', $view);
}
public function test_ticket_create_shows_uploaded_temporary_file_names_from_livewire_property(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('@if (count($attachments) > 0)', $view);
self::assertStringContainsString('$attachment->getClientOriginalName()', $view);
self::assertStringContainsString('wire:loading wire:target="attachments"', $view);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateAttachmentPersistenceTest extends TestCase
{
/**
* The create form must not submit while Livewire is still uploading files,
* otherwise the ticket can be created before attachments reach the component.
*/
public function test_ticket_create_submit_is_blocked_while_attachments_are_uploading(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"', $view);
$this->assertStringContainsString('x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('wire:target="save,attachments"', $view);
$this->assertStringContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view);
$this->assertStringContainsString('$event.preventDefault();', $view);
}
/**
* The shared attachment service must explicitly support Livewire temporary
* uploads as well as controller UploadedFile instances.
*/
public function test_attachment_service_supports_livewire_temporary_uploads(): void
{
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$this->assertIsString($service);
$this->assertStringContainsString('TemporaryUploadedFile', $service);
$this->assertStringContainsString('isSupportedUpload', $service);
$this->assertStringContainsString('$file->store(\'tickets/\' . $ticket->id, $disk)', $service);
$this->assertStringNotContainsString('! $file instanceof UploadedFile || ! $file->isValid()', $service);
}
}
@@ -0,0 +1,143 @@
<?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 Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketCreateAttachmentPipelineTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_ticket_create_persists_a_single_attachment_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Attachment Create User']);
$file = UploadedFile::fake()->create('warehouse-error.log', 12, 'text/plain');
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with one attachment')
->set('body', '<p>This ticket must persist a Livewire upload attachment.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['high']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [$file])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with one attachment')
->firstOrFail();
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->whereNull('comment_id')
->firstOrFail();
self::assertSame($user->id, (int) $attachment->user_id);
self::assertSame('local', $attachment->disk);
self::assertSame('warehouse-error.log', $attachment->original_name);
self::assertSame('log', $attachment->extension());
Storage::disk('local')->assertExists($attachment->path);
}
public function test_ticket_create_persists_multiple_attachments_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Multiple Attachment User']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with multiple attachments')
->set('body', '<p>This ticket must persist every selected Livewire upload.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['normal']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [
UploadedFile::fake()->create('first-proof.pdf', 20, 'application/pdf'),
UploadedFile::fake()->create('second-proof.txt', 8, 'text/plain'),
UploadedFile::fake()->create('third-proof.csv', 6, 'text/csv'),
])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with multiple attachments')
->firstOrFail();
self::assertSame(3, Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count());
foreach (['first-proof.pdf', 'second-proof.txt', 'third-proof.csv'] as $name) {
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->where('original_name', $name)
->firstOrFail();
Storage::disk('local')->assertExists($attachment->path);
}
}
public function test_ticket_create_accepts_source_code_attachments_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Source Attachment User']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with source attachments')
->set('body', '<p>This ticket must accept source files by extension.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['normal']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [
UploadedFile::fake()->create('TicketCreate.php', 10, 'text/plain'),
UploadedFile::fake()->create('upload-widget.js', 10, 'text/plain'),
UploadedFile::fake()->create('warehouse-sync.rpgle', 10, 'text/plain'),
])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with source attachments')
->firstOrFail();
foreach (['TicketCreate.php', 'upload-widget.js', 'warehouse-sync.rpgle'] as $name) {
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->where('original_name', $name)
->firstOrFail();
self::assertTrue($attachment->isSourceCode(), $name . ' should be categorised as source code.');
Storage::disk('local')->assertExists($attachment->path);
}
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
/**
* Guards browser-native spellcheck wiring on Helpdesk ticket create and edit forms.
*/
final class TicketCreateEditSpellcheckFilesTest extends TestCase
{
/**
* Ensure the create form subject and body editor remain wired for native browser spellcheck.
*/
public function test_create_ticket_subject_and_body_keep_native_spellcheck_hints(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-create.blade.php');
$editor = file_get_contents(__DIR__ . '/../../Resources/views/components/html-editor.blade.php');
self::assertIsString($view);
self::assertIsString($editor);
self::assertStringContainsString('id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $view);
self::assertStringContainsString('model="body"', $view);
self::assertStringContainsString('editor-dusk="ticket-body-editor"', $view);
self::assertStringContainsString('contenteditable="true"', $editor);
self::assertStringContainsString('spellcheck="true"', $editor);
self::assertStringContainsString('lang="en-GB"', $editor);
self::assertStringContainsString('autocorrect="on"', $editor);
self::assertStringContainsString('autocomplete="on"', $editor);
self::assertStringContainsString('autocapitalize="sentences"', $editor);
self::assertStringContainsString('data-helpdesk-spellcheck-mirror="{{ $model }}"', $editor);
}
/**
* Ensure the edit form subject and body editor use the same spellcheck baseline as create.
*/
public function test_edit_ticket_subject_and_body_keep_native_spellcheck_hints(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-edit.blade.php');
$editor = file_get_contents(__DIR__ . '/../../Resources/views/components/html-editor.blade.php');
self::assertIsString($view);
self::assertIsString($editor);
self::assertStringContainsString('id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $view);
self::assertStringContainsString('model="body"', $view);
self::assertStringContainsString('editor-dusk="ticket-edit-body-editor"', $view);
self::assertStringContainsString('data-helpdesk-spellcheck-mirror="{{ $model }}"', $editor);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateEditorSyncFilesTest extends TestCase
{
/**
* The ticket create editor must not send Livewire requests while typing.
* Eager key/input sync causes disabled submit states, random content
* replacement, and prevents native spellcheck from settling.
*/
public function test_ticket_create_editor_does_not_sync_livewire_on_keypress_or_input(): void
{
$editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$this->assertIsString($editor);
$this->assertStringNotContainsString('x-on:input', $editor);
$this->assertStringNotContainsString('x-on:keyup', $editor);
$this->assertStringContainsString('contenteditable="true"', $editor);
$this->assertStringContainsString('spellcheck="true"', $editor);
$this->assertStringContainsString('lang="en-GB"', $editor);
$this->assertStringContainsString('this.$wire.set(this.model, this.value, live)', $editor);
$this->assertStringContainsString('syncToLivewire(false)', $editor);
}
/**
* The create form must push the rich editor body to the Livewire property
* during submit capture, before the save action validates the body field.
*/
public function test_ticket_create_form_syncs_body_before_submit_validation(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('model="body"', $view);
$this->assertStringContainsString('x-on:submit.capture', $view);
$this->assertStringContainsString('helpdesk-editor-sync', $view);
$this->assertStringNotContainsString('wire:model.live="body"', $view);
}
/**
* The create-ticket footer buttons should match the compact professional
* pattern already used by Ticket controls.
*/
public function test_ticket_create_footer_buttons_match_ticket_controls_style(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('Create ticket', $view);
$this->assertStringNotContainsString('rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold', $view);
}
/**
* Browser-native spellcheck must remain enabled without forcing Livewire
* updates on every keystroke because that breaks typing and spellcheck.
*/
public function test_ticket_create_editor_keeps_spellcheck_without_live_keypress_sync(): void
{
$component = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($component);
$this->assertIsString($view);
$this->assertStringContainsString('spellcheck="true"', $component);
$this->assertStringContainsString('autocorrect="on"', $component);
$this->assertStringContainsString('lang="en-GB"', $component);
$this->assertStringNotContainsString('x-on:input', $component);
$this->assertStringNotContainsString('x-on:keyup', $component);
$this->assertStringContainsString('x-on:submit.capture', $view);
$this->assertStringContainsString('helpdesk-editor-sync', $view);
}
}
@@ -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\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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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_does_not_validate_before_submit(): void
{
$this->seedLookupData();
$user = $this->user();
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Draft ticket title only')
->assertHasNoErrors();
}
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,70 @@
<?php
/**
* TicketDeveloperActionsUiFilesTest.php
*
* Guards the ticket-level developer actions workflow against UI regressions.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketDeveloperActionsUiFilesTest extends TestCase
{
/**
* Ensure the Git activity card has been promoted to a complete developer actions panel.
*/
public function test_ticket_show_exposes_developer_actions_panel(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-developer-actions', $view);
self::assertStringContainsString('Developer actions', $view);
self::assertStringContainsString('Open repo', $view);
self::assertStringContainsString('Open branch', $view);
self::assertStringContainsString('Pull requests', $view);
self::assertStringContainsString('Activity status', $view);
}
/**
* Ensure the panel keeps copyable GitFlow branch, commit and pull request templates.
*/
public function test_ticket_show_exposes_copyable_gitflow_templates(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('Branch create command', $view);
self::assertStringContainsString('\'git checkout -b bugfix/\' . $ticket->reference . \'-\'', $view);
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
self::assertStringContainsString('$gitflowPrefixes', $view);
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
self::assertStringContainsString('Commit message', $view);
self::assertStringContainsString('Pull request title', $view);
self::assertStringContainsString('data-helpdesk-copy-template', $view);
self::assertStringContainsString('data-helpdesk-copy-template-button', $view);
self::assertStringContainsString('copyHelpdeskTemplate', $view);
self::assertStringContainsString('copyTextToClipboard', $view);
}
/**
* Ensure linked Git activity still renders branch and commit badges instead of being hidden by the new panel.
*/
public function test_ticket_show_still_renders_linked_git_activity_rows(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('@forelse ($gitActivity as $commit)', $view);
self::assertStringContainsString('$isBranchActivity = $commit->short_hash === \'branch\';', $view);
self::assertStringContainsString('{{ $isBranchActivity ? \'Branch\' : \'Commit\' }}', $view);
self::assertStringContainsString('No Git activity linked.', $view);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketFirstResponseSlaFilesTest extends TestCase
{
/**
* Public replies must satisfy the first-response SLA instead of only incrementing the counter.
*/
public function test_public_response_updates_first_response_sla_state(): void
{
$servicePath = dirname(__DIR__, 2) . '/Services/SlaService.php';
self::assertFileExists($servicePath);
$service = file_get_contents($servicePath);
self::assertIsString($service);
self::assertStringContainsString('public function recordPublicResponse(Ticket $ticket, User $actor): void', $service);
self::assertStringContainsString('markFirstResponse($ticket, $respondedAt, $actor)', $service);
self::assertStringNotContainsString('(int) $ticket->user_id !== (int) $actor->id', $service);
}
/**
* Existing tickets with public comments but a missing first_response_at are repaired on display.
*/
public function test_ticket_show_synchronises_first_response_from_existing_public_comments(): void
{
$componentPath = dirname(__DIR__, 2) . '/Livewire/TicketShow.php';
$servicePath = dirname(__DIR__, 2) . '/Services/SlaService.php';
$component = file_get_contents($componentPath);
$service = file_get_contents($servicePath);
self::assertIsString($component);
self::assertIsString($service);
self::assertStringContainsString('syncFirstResponseFromComments($ticket)', $component);
self::assertStringContainsString("where('is_internal', false)", $service);
self::assertStringContainsString("whereNull('tombstoned_at')", $service);
}
/**
* The SLA card should show an accurate response state instead of Awaiting response after public replies exist.
*/
public function test_sla_card_has_clear_responded_and_awaiting_public_reply_states(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('Responded', $view);
self::assertStringContainsString('Awaiting public reply', $view);
self::assertStringNotContainsString("?? 'Awaiting response'", $view);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketGitCommitDisplayFilesTest extends TestCase
{
public function test_ticket_show_uses_safe_bound_git_commit_lookup_without_raw_like_sql(): void
{
$component = file_get_contents(__DIR__ . '/../../Livewire/TicketShow.php');
self::assertIsString($component);
self::assertStringContainsString("setRelation('gitCommits'", $component);
self::assertStringContainsString("orWhere('message', 'ILIKE'", $component);
self::assertStringContainsString("orWhere('branch', 'ILIKE'", $component);
self::assertStringContainsString("orWhere('url', 'ILIKE'", $component);
self::assertStringNotContainsString('orWhereRaw', $component);
self::assertStringNotContainsString('lower(message) like', strtolower($component));
}
/**
* Directly linked commits must still display even when historical repository ownership is inconsistent.
*/
public function test_ticket_show_keeps_directly_linked_git_commits_visible_before_repository_scope(): void
{
$component = file_get_contents(__DIR__ . '/../../Livewire/TicketShow.php');
self::assertIsString($component);
self::assertStringContainsString("\$scopeQuery->where('ticket_id', \$ticket->id)", $component);
self::assertStringContainsString("->orWhereHas('repository'", $component);
self::assertStringContainsString("HD-2026-00026", "HD-2026-00026");
}
public function test_ticket_show_preserves_livewire_safe_expandable_sidebar_cards(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-show.blade.php');
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-sidebar-cards', $view);
self::assertStringContainsString('livewire:navigated', $view);
self::assertStringContainsString('data-helpdesk-sidebar-card-header', $view);
self::assertStringNotContainsString("document.createElement('details')", $view);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketIndexDirectUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_index_eager_loads_required_relations(): void
{
$source = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
self::assertIsString($source);
self::assertStringContainsString("->with(['status', 'priority', 'category', 'project', 'team'])", $source);
self::assertStringNotContainsString("'user:id,name,email'", $source);
self::assertStringNotContainsString("'owner:id,name,email'", $source);
self::assertStringNotContainsString("'agent:id,name,email'", $source);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketIndexUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_index_uses_scalar_agent_name(): void
{
$component = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
$view = file_get_contents(dirname(__DIR__, 2).'/Resources/views/livewire/ticket-index.blade.php');
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString("->select('helpdesk_tickets.*')", $component);
self::assertStringContainsString("'agent_name' => User::query()", $component);
self::assertStringContainsString("->whereColumn('users.id', 'helpdesk_tickets.agent_id')", $component);
self::assertStringContainsString('$ticket->agent_name ?? \'Unassigned\'', $view);
self::assertStringNotContainsString('$ticket->agent?->name ?? \'Unassigned\'', $view);
}
}
@@ -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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketPublicResponseVisibilityFilesTest extends TestCase
{
/**
* Ensure the main ticket response form has an explicit public/internal visibility choice.
*/
public function test_main_ticket_response_form_has_public_visibility_control(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('Visibility', $view);
self::assertStringContainsString('Public reply', $view);
self::assertStringContainsString('Internal note', $view);
self::assertStringContainsString('name="internal" value="0"', $view);
self::assertStringContainsString('name="internal" value="1"', $view);
self::assertStringContainsString('counts as the first SLA response', $view);
}
/**
* Ensure threaded responses have the same public/internal visibility choice and visible badges.
*/
public function test_threaded_responses_have_public_visibility_control_and_badges(): void
{
$partialPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php';
self::assertFileExists($partialPath);
$partial = file_get_contents($partialPath);
self::assertIsString($partial);
self::assertStringContainsString('Public reply', $partial);
self::assertStringContainsString('Internal note', $partial);
self::assertStringContainsString('name="internal" value="0" checked', $partial);
self::assertStringContainsString('name="internal" value="1"', $partial);
self::assertStringContainsString('>Public</span>', $partial);
self::assertStringContainsString('>Internal</span>', $partial);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketShowCommentRelationsFilesTest extends TestCase
{
public function test_ticket_show_only_eager_loads_real_comment_relations(): void
{
$source = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketShow.php');
self::assertIsString($source);
self::assertStringContainsString("->with(['user.company', 'attachments'])", $source);
self::assertStringNotContainsString("->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments'])", $source);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
/**
* Verifies that Helpdesk ticket sidebar cards use the same expandable header pattern as Ticket controls.
*/
final class TicketSidebarCardIconsFilesTest extends TestCase
{
/**
* Sidebar cards must use a real summary chevron control, matching the Ticket controls card.
*/
public function test_ticket_sidebar_cards_use_ticket_controls_chevron_pattern(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString("details.className = Array.from(classNames).join(' ');", $view);
$this->assertStringContainsString("classNames.add('group');", $view);
$this->assertStringContainsString("'justify-between'", $view);
$this->assertStringContainsString("'marker:hidden'", $view);
$this->assertStringContainsString('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20', $view);
$this->assertStringContainsString('fa-solid fa-chevron-right text-xs transition group-open:rotate-90', $view);
}
/**
* The old pseudo-element fallback produced inconsistent square icons and must stay removed.
*/
public function test_ticket_sidebar_cards_do_not_use_pseudo_element_chevrons(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringNotContainsString('[data-helpdesk-sidebar-cards] > div > :first-child::after', $view);
$this->assertStringNotContainsString('border-right: .16rem solid currentColor;', $view);
$this->assertStringNotContainsString('content: "\\f078";', $view);
}
/**
* Livewire navigated ticket pages must initialise the sidebar cards again.
*/
public function test_ticket_sidebar_cards_initialise_after_livewire_navigation(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('initialiseHelpdeskTicketShowInteractions', $view);
$this->assertStringContainsString("document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);", $view);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketSidebarCardsExpandableFilesTest extends TestCase
{
/**
* Ticket show sidebar cards must be expandable after normal page loads and Livewire navigation.
*/
public function test_ticket_show_sidebar_cards_have_livewire_safe_expand_toggle(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-sidebar-cards', $view);
self::assertStringContainsString('data-helpdesk-sidebar-card-header', $view);
self::assertStringContainsString('toggleHelpdeskSidebarCard', $view);
self::assertStringContainsString("document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);", $view);
self::assertStringContainsString("document.addEventListener('livewire:init', initialiseHelpdeskTicketShowInteractions);", $view);
self::assertStringContainsString("card.dataset.open = isOpen ? 'false' : 'true';", $view);
self::assertStringContainsString("header.setAttribute('aria-expanded', isOpen ? 'false' : 'true');", $view);
}
/**
* Inline styles must stay inside the Livewire root element so the component root remains stable.
*/
public function test_ticket_show_does_not_place_style_before_livewire_root(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertLessThan(
strpos($view, '<style>'),
strpos($view, '<div data-dusk="helpdesk-ticket-show"'),
);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketSlaOperationalDetailFilesTest extends TestCase
{
/**
* SLA cards must show operationally useful first-response detail.
*/
public function test_first_response_sla_card_shows_duration_responder_and_timestamp(): void
{
$componentPath = dirname(__DIR__, 2) . '/Livewire/TicketShow.php';
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$component = file_get_contents($componentPath);
$view = file_get_contents($viewPath);
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString('firstResponseSlaDetail', $component);
self::assertStringContainsString('Responded in ', $component);
self::assertStringContainsString('firstResponderName', $component);
self::assertStringContainsString('Responder', $view);
self::assertStringContainsString('Responded at', $view);
}
/**
* SLA cards should use clear met, due-soon and breached states.
*/
public function test_sla_cards_have_colour_coded_state_helpers(): void
{
$componentPath = dirname(__DIR__, 2) . '/Livewire/TicketShow.php';
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$component = file_get_contents($componentPath);
$view = file_get_contents($viewPath);
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString('firstResponseIsDueSoon', $component);
self::assertStringContainsString('resolutionIsDueSoon', $component);
self::assertStringContainsString('firstResponseSlaClasses', $view);
self::assertStringContainsString('resolutionSlaClasses', $view);
self::assertStringContainsString('border-amber-300', $component);
self::assertStringContainsString('border-red-300', $component);
self::assertStringContainsString('border-emerald-200', $component);
}
/**
* The ticket page should expose an SLA audit trail for support managers.
*/
public function test_sla_audit_trail_is_visible_on_ticket_page(): void
{
$servicePath = dirname(__DIR__, 2) . '/Services/SlaService.php';
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$service = file_get_contents($servicePath);
$view = file_get_contents($viewPath);
self::assertIsString($service);
self::assertIsString($view);
self::assertStringContainsString('SLA audit trail', $view);
self::assertStringContainsString('$ticket->slaEvents->take(5)', $view);
self::assertStringContainsString('actor_name', $service);
self::assertStringContainsString('response_seconds', $service);
self::assertStringContainsString('resolved_seconds', $service);
}
/**
* Public reply counts must include threaded public replies, not only root comments.
*/
public function test_public_reply_count_uses_all_ticket_comments(): void
{
$componentPath = dirname(__DIR__, 2) . '/Livewire/TicketShow.php';
$component = file_get_contents($componentPath);
self::assertIsString($component);
self::assertStringContainsString('Comment::query()', $component);
self::assertStringContainsString("where('ticket_id', \$ticket->id)", $component);
self::assertStringContainsString("\$comments->where('is_internal', false)->count()", $component);
self::assertStringNotContainsString('$ticket->comments()
->active()', $component);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketSourceAttachmentFilesTest extends TestCase
{
/**
* Source-code uploads must be extension based because browsers and Symfony
* often report source files as text/plain or application/octet-stream.
*/
public function test_attachment_validation_uses_extension_allow_list_for_source_files(): void
{
$config = file_get_contents(base_path('Modules/Helpdesk/Config/config.php'));
$this->assertIsString($config);
$this->assertStringContainsString("'allowed_extensions'", $config);
$this->assertStringContainsString("'php'", $config);
$this->assertStringContainsString("'js'", $config);
$this->assertStringContainsString("'rpg'", $config);
$this->assertStringContainsString("'rpgle'", $config);
}
/**
* All Helpdesk upload entry points should use the shared source-safe rules.
*/
public function test_helpdesk_upload_entry_points_use_shared_attachment_rules(): void
{
$files = [
'Modules/Helpdesk/Livewire/TicketCreate.php',
'Modules/Helpdesk/Livewire/TicketEdit.php',
'Modules/Helpdesk/Livewire/TicketShow.php',
'Modules/Helpdesk/Http/Controllers/TicketStoreController.php',
'Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php',
'Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php',
];
foreach ($files as $file) {
$contents = file_get_contents(base_path($file));
$this->assertIsString($contents);
$this->assertStringContainsString('extensions:', $contents, $file);
$this->assertStringNotContainsString('mimes:', $contents, $file);
$this->assertStringNotContainsString('AttachmentValidation', $contents, $file);
}
}
}
@@ -0,0 +1,69 @@
<?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 TicketTimeLogTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
/**
* It stores a time log for an authorized ticket agent.
*/
public function test_authorized_user_can_log_ticket_time(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Ticket Owner']);
$agent = $this->agent(['name' => 'Assigned Agent']);
$ticket = $this->ticket([
'owner' => $owner,
'agent' => $agent,
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['normal']->id,
]);
$this->actingAs($agent)
->post(route('helpdesk.tickets.time-logs.store', $ticket), [
'minutes' => 45,
'description' => 'Investigated the issue and updated the ticket.',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$this->assertDatabaseHas('helpdesk_time_logs', [
'ticket_id' => $ticket->id,
'user_id' => $agent->id,
'minutes' => 45,
'description' => 'Investigated the issue and updated the ticket.',
]);
}
/**
* It rejects invalid logged minutes for an authorized ticket agent.
*/
public function test_ticket_time_log_requires_valid_minutes(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Ticket Owner']);
$agent = $this->agent(['name' => 'Assigned Agent']);
$ticket = $this->ticket([
'owner' => $owner,
'agent' => $agent,
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['normal']->id,
]);
$this->actingAs($agent)
->from(route('helpdesk.tickets.show', $ticket))
->post(route('helpdesk.tickets.time-logs.store', $ticket), [
'minutes' => 0,
])
->assertRedirect(route('helpdesk.tickets.show', $ticket))
->assertSessionHasErrors('minutes');
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketTimerButtonSizeFilesTest extends TestCase
{
/**
* Ensure the inactive timer action remains readable, single-line, and does not reintroduce unused script files.
*/
public function test_start_timer_button_uses_readable_single_line_professional_classes(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-timer.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-dusk="start-ticket-timer"', $view);
self::assertStringContainsString('min-w-[9.5rem]', $view);
self::assertStringContainsString('whitespace-nowrap rounded-md bg-blue-600 px-4 py-2 text-sm', $view);
self::assertStringNotContainsString('px-3 py-1.5 text-xs', $view);
self::assertStringNotContainsString('ticket-timer.js', $view);
}
/**
* Ensure the active timer action also remains readable and single-line.
*/
public function test_end_timer_button_uses_matching_readable_single_line_professional_classes(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-timer.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-dusk="end-ticket-timer"', $view);
self::assertStringContainsString('min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-red-600 px-4 py-2 text-sm', $view);
self::assertStringNotContainsString('rounded-lg bg-red-600 px-4 py-2.5 text-sm', $view);
self::assertStringNotContainsString('ticket-timer.js', $view);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerCleanUiFilesTest extends TestCase
{
/**
* Timer markup should live in one loaded component without duplicated control-panel timer displays.
*/
public function test_ticket_controls_do_not_duplicate_timer_display_logic(): void
{
$controls = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-controls.blade.php'));
$timer = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$provider = (string) file_get_contents(base_path('Modules/Helpdesk/Providers/HelpdeskServiceProvider.php'));
$this->assertStringContainsString("@include('helpdesk::partials.ticket-time-timer'", $controls);
$this->assertStringNotContainsString('data-helpdesk-running-summary-badge', $controls);
$this->assertStringNotContainsString('data-helpdesk-panel-elapsed', $controls);
$this->assertStringNotContainsString('MutationObserver', $controls);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $timer);
$this->assertStringContainsString("Livewire::component('helpdesk.ticket-timer'", $provider);
}
/**
* The module must not contain accidental duplicate Blade folders or macOS artefacts.
*/
public function test_helpdesk_module_does_not_contain_known_rubbish_files(): void
{
$this->assertDirectoryDoesNotExist(base_path('Modules/Helpdesk/resources/views/views'));
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/.DS_Store'));
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/resources/.DS_Store'));
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerFilesTest extends TestCase
{
/**
* The Livewire timer view must keep its root element first.
*/
public function test_ticket_timer_view_does_not_start_with_a_style_tag(): void
{
$view = trim((string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')));
$this->assertStringStartsWith('<div', $view);
$this->assertFalse(
str_starts_with($view, '<style'),
'The ticket timer Livewire view must not start with a <style> tag.',
);
}
/**
* The timer must be database backed rather than session/localStorage backed.
*/
public function test_ticket_timer_is_not_session_or_local_storage_backed(): void
{
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringNotContainsString('session(', $component);
$this->assertStringNotContainsString('localStorage', $component);
$this->assertStringNotContainsString('localStorage', $view);
$this->assertStringContainsString('whereNull', $component);
$this->assertStringContainsString('activeUserTimerQuery', $component);
$this->assertStringContainsString('TicketTimeLog::query()', $component);
$this->assertStringNotContainsString('->timeLogs()', $component);
}
/**
* The ticket page must include the user-specific timer partial.
*/
public function test_ticket_show_timer_partial_exists(): void
{
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php'));
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$this->assertFileExists(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerLiveDisplayFilesTest extends TestCase
{
/**
* The timer display must tick in the browser from the database-backed started_at value.
*/
public function test_timer_view_has_browser_side_running_display_without_local_storage(): void
{
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringContainsString('Date.parse', $view);
$this->assertStringContainsString('setInterval', $view);
$this->assertStringContainsString('data-helpdesk-started-at', $view);
$this->assertStringContainsString('data-dusk="ticket-running-time"', $view);
$this->assertStringNotContainsString('localStorage', $view);
$this->assertStringNotContainsString('sessionStorage', $view);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerSecondsFilesTest extends TestCase
{
/**
* Timer files must support seconds storage and display.
*/
public function test_timer_supports_seconds_storage_and_display(): void
{
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
$model = (string) file_get_contents(base_path('Modules/Helpdesk/Models/TicketTimeLog.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringContainsString("'seconds'", $component);
$this->assertStringContainsString("'seconds'", $model);
$this->assertStringContainsString('diffInSeconds', $component);
$this->assertStringContainsString("sum('seconds')", $component);
$this->assertStringContainsString('formatElapsed', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringNotContainsString('localStorage', $component);
$this->assertStringNotContainsString('localStorage', $view);
}
/**
* The history query partial should read persisted timer logs directly.
*/
public function test_history_query_reads_ticket_time_logs_directly(): void
{
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php'));
$this->assertStringContainsString('TicketTimeLog::query()', $partial);
$this->assertStringContainsString("whereNotNull('ended_at')", $partial);
$this->assertStringContainsString('seconds', $partial);
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerStartDisplayFilesTest extends TestCase
{
/**
* The component must hydrate the active timer immediately after start so the running card displays.
*/
public function test_start_timer_refreshes_running_display_state(): void
{
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php'));
$this->assertStringContainsString('$this->activeTimer = $timer->fresh();', $component);
$this->assertStringContainsString('$this->elapsedSeconds = 0;', $component);
$this->assertStringContainsString("dispatch('timer-started')", $component);
$this->assertStringContainsString('$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringContainsString("@livewire('helpdesk.ticket-timer'", $partial);
$this->assertStringNotContainsString('wire:ignore', $view);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_queries_only_eager_load_required_relations(): void
{
$index = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
$kanban = file_get_contents(dirname(__DIR__, 2).'/Livewire/KanbanBoard.php');
$dashboard = file_get_contents(dirname(__DIR__, 2).'/Livewire/HelpdeskDashboardWidget.php');
self::assertIsString($index);
self::assertIsString($kanban);
self::assertIsString($dashboard);
// Index deliberately avoids loading user relations.
self::assertStringNotContainsString("'user:id,name,email'", $index);
self::assertStringNotContainsString("'owner:id,name,email'", $index);
self::assertStringNotContainsString("'agent:id,name,email'", $index);
self::assertStringContainsString("'owner:id,name,email'", $kanban);
self::assertStringContainsString("'agent:id,name,email'", $kanban);
// Dashboard intentionally only loads the relations it renders.
self::assertStringContainsString("->with(['status', 'priority'])", $dashboard);
self::assertStringNotContainsString("'owner:id,name,email'", $dashboard);
self::assertStringNotContainsString("'agent:id,name,email'", $dashboard);
}
}
@@ -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 BuildsHelpdeskFixtures;
use RefreshDatabase;
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',
]);
}
}