Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 deletions
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskCreateTicketAttachmentBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
public function test_user_can_create_ticket_with_attachment_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Browser Attachment User']);
$subject = 'Browser attachment ticket ' . uniqid('', true);
$filePath = $this->makeBrowserUploadFile('browser-upload-proof.log', 'Browser upload proof file.');
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $filePath): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->type('@ticket-subject', $subject);
$browser->attach('#attachments', $filePath);
$this->waitForHelpdeskUploadToFinish($browser);
$browser->type('@ticket-subject', $subject);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves attachments can be uploaded and saved.',
);
$this->submitTicketCreateComponent($browser);
$this->waitForTicketCreated($browser, $subject);
$browser->visitRoute('helpdesk.tickets.index')
->waitForText($subject, 10)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
});
$ticket = Ticket::query()
->where('subject', $subject)
->firstOrFail();
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->whereNull('comment_id')
->where('original_name', 'browser-upload-proof.log')
->firstOrFail();
self::assertSame($ticket->id, (int) $attachment->ticket_id);
}
public function test_user_can_create_ticket_with_multiple_attachments_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Browser Multi Attachment User']);
$subject = 'Browser multiple attachment ticket ' . uniqid('', true);
$firstFile = $this->makeBrowserUploadFile('browser-first-proof.txt', 'First browser upload proof file.');
$secondFile = $this->makeBrowserUploadFile('browser-second-proof.csv', 'name,value' . PHP_EOL . 'proof,1');
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $firstFile, $secondFile): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->type('@ticket-subject', $subject);
$browser->attach('#attachments', $firstFile . "\n" . $secondFile);
$this->waitForHelpdeskUploadToFinish($browser);
$browser->type('@ticket-subject', $subject);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['normal']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves multiple attachments can be uploaded and saved.',
);
$this->submitTicketCreateComponent($browser);
$this->waitForTicketCreated($browser, $subject);
$browser->visitRoute('helpdesk.tickets.index')
->waitForText($subject, 10)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
});
$ticket = Ticket::query()
->where('subject', $subject)
->firstOrFail();
self::assertSame(
2,
Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count(),
);
foreach (['browser-first-proof.txt', 'browser-second-proof.csv'] as $name) {
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => $name,
]);
}
}
private function makeBrowserUploadFile(string $name, string $contents): string
{
$directory = storage_path('framework/testing/helpdesk-browser-uploads');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$path = $directory . DIRECTORY_SEPARATOR . $name;
file_put_contents($path, $contents);
return $path;
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $text): void
{
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
$encodedText = json_encode($text, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const key = {$encodedDusk};
const text = {$encodedText};
const root = document.querySelector('[dusk="' + key + '"], [data-dusk="' + key + '"]');
if (! root) {
throw new Error('Helpdesk editor ' + key + ' was not found.');
}
const editor = root.matches('textarea, input, [contenteditable="true"]')
? root
: root.querySelector('textarea, input, [contenteditable="true"]');
if (! editor) {
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
}
editor.focus();
if (editor.matches('textarea, input')) {
editor.value = text;
} else {
editor.textContent = text;
}
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertText',
data: text,
}));
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
editor.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
const model = root.getAttribute('wire:model')
|| root.getAttribute('wire:model.live')
|| root.getAttribute('wire:model.defer')
|| root.getAttribute('data-wire-model')
|| editor.getAttribute('data-wire-model')
|| editor.getAttribute('data-helpdesk-editor-model')
|| editor.getAttribute('data-helpdesk-sync-target');
let componentRoot = root;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (model && componentRoot && window.Livewire) {
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (component) {
component.set(model, text);
}
}
JS);
$browser->pause(500);
}
private function setHelpdeskSelect(Browser $browser, string $model, int|string|null $value): void
{
$encodedModel = json_encode($model, JSON_THROW_ON_ERROR);
$encodedValue = json_encode($value === null ? '' : (string) $value, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const model = {$encodedModel};
const value = {$encodedValue};
const select = document.querySelector('[data-wire-model="' + model + '"]');
if (! select) {
throw new Error('Helpdesk select ' + model + ' was not found.');
}
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
if (window.jQuery) {
window.jQuery(select).val(value).trigger('change.select2').trigger('change.helpdesk-select2').trigger('change');
}
let componentRoot = select;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (componentRoot && window.Livewire) {
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (component) {
component.set(model, value === '' ? null : value);
}
}
JS);
$browser->pause(250);
}
private function submitTicketCreateComponent(Browser $browser): void
{
$browser->waitUsing(10, 100, static function () use ($browser): bool {
return (bool) $browser->script(<<<'JS'
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
return Boolean(button) && button.disabled === false;
JS)[0];
}, 'The helpdesk ticket create form was not ready to submit.');
$browser->script(<<<'JS'
const root = document.querySelector('[dusk="helpdesk-ticket-create"], [data-dusk="helpdesk-ticket-create"]');
if (! root) {
throw new Error('Helpdesk ticket create root was not found.');
}
let componentRoot = root;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (! componentRoot || ! window.Livewire) {
throw new Error('Helpdesk Livewire create component was not found.');
}
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (! component) {
throw new Error('Helpdesk Livewire create component instance was not found.');
}
component.call('save');
JS);
}
private function waitForHelpdeskUploadToFinish(Browser $browser): void
{
$browser->waitUsing(30, 250, static function () use ($browser): bool {
return (bool) $browser->script(<<<'JS'
const input = document.querySelector('#attachments');
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
const selected = document.querySelector('[dusk="ticket-create-selected-attachments"], [data-dusk="ticket-create-selected-attachments"]');
return Boolean(input)
&& input.files.length > 0
&& Boolean(selected)
&& Boolean(button)
&& button.disabled === false;
JS)[0];
}, 'The Livewire temporary attachment upload did not finish before submit.');
}
private function waitForTicketCreated(Browser $browser, string $subject): void
{
$browser->waitUsing(20, 250, static fn (): bool => Ticket::query()
->where('subject', $subject)
->exists(), 'The browser did not create the helpdesk ticket before the timeout.');
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
/**
* Browser regression coverage for the Helpdesk Create Ticket editor and footer actions.
*
* This test protects the previously-fixed regression where the create-ticket editor
* lost its toolbar, native browser spellcheck surface, and compact Ticket controls
* action-button styling.
*/
final class HelpdeskCreateTicketEditorBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
/**
* Ensure the create-ticket editor keeps toolbar controls, contenteditable spellcheck,
* and compact footer buttons aligned with the Ticket controls styling baseline.
*/
public function test_create_ticket_editor_toolbar_spellcheck_and_action_buttons_are_rendered_correctly(): void
{
$this->seedLookupData();
$user = $this->user([
'name' => 'Create Ticket Editor Regression User',
]);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->assertVisible('@ticket-create-submit')
->assertVisible('@ticket-create-clear');
/** @var array{exists: bool, visible: bool, buttonCount: int, commands: array<int, string|null>} $toolbar */
$toolbar = $browser->script(<<<'JS'
const toolbar = document.querySelector('[data-helpdesk-editor-toolbar]');
const buttons = toolbar ? Array.from(toolbar.querySelectorAll('button[data-helpdesk-editor-command]')) : [];
return {
exists: Boolean(toolbar),
visible: Boolean(toolbar && toolbar.offsetParent !== null),
buttonCount: buttons.length,
commands: buttons.map((button) => button.getAttribute('data-helpdesk-editor-command')),
};
JS)[0];
$this->assertTrue($toolbar['exists'], 'The create-ticket editor toolbar was not rendered.');
$this->assertTrue($toolbar['visible'], 'The create-ticket editor toolbar was rendered but is not visible.');
$this->assertGreaterThanOrEqual(4, $toolbar['buttonCount'], 'The create-ticket editor toolbar should expose formatting buttons.');
$this->assertContains('bold', $toolbar['commands'], 'The create-ticket editor toolbar is missing the bold button.');
$this->assertContains('italic', $toolbar['commands'], 'The create-ticket editor toolbar is missing the italic button.');
/** @var array{exists: bool, isContentEditable: bool, contenteditable: string|null, spellcheck: string|null, lang: string|null} $editor */
$editor = $browser->script(<<<'JS'
const root = document.querySelector('[dusk="ticket-body-editor"], [data-dusk="ticket-body-editor"]');
const editor = root && root.matches('[contenteditable="true"]')
? root
: root?.querySelector('[contenteditable="true"]');
return {
exists: Boolean(editor),
isContentEditable: Boolean(editor?.isContentEditable),
contenteditable: editor?.getAttribute('contenteditable') || null,
spellcheck: editor?.getAttribute('spellcheck') || null,
lang: editor?.getAttribute('lang') || null,
};
JS)[0];
$this->assertTrue($editor['exists'], 'The create-ticket editor contenteditable surface was not rendered.');
$this->assertTrue($editor['isContentEditable'], 'The create-ticket editor must use a native contenteditable surface.');
$this->assertSame('true', $editor['contenteditable'], 'The create-ticket editor must keep contenteditable="true".');
$this->assertSame('true', $editor['spellcheck'], 'The create-ticket editor must keep browser spellcheck enabled.');
$this->assertSame('en-GB', $editor['lang'], 'The create-ticket editor must use British English spellcheck language.');
/** @var array{create: array<int, string>, clear: array<int, string>, cancel: array<int, string>} $classes */
$classes = $browser->script(<<<'JS'
const classList = (selector) => Array.from(document.querySelector(selector)?.classList || []);
return {
create: classList('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]'),
clear: classList('[dusk="ticket-create-clear"], [data-dusk="ticket-create-clear"]'),
cancel: classList('[dusk="ticket-create-cancel"], [data-dusk="ticket-create-cancel"]'),
};
JS)[0];
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['create'], "The Create ticket button is missing the {$requiredClass} Ticket controls class.");
}
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['clear'], "The Clear button is missing the {$requiredClass} Ticket controls class.");
}
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['cancel'], "The Cancel button is missing the {$requiredClass} Ticket controls class.");
}
});
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskKanbanBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
use DatabaseMigrations;
/** @test */
public function kanban_board_is_available_from_helpdesk_module(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['high']->id,
'subject' => 'Kanban browser ticket',
]);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.kanban')
->assertPresent('@helpdesk-kanban-board')
->assertSee('Kanban board')
->assertSee('Kanban browser ticket')
->assertPresent('@kanban-column-open');
});
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskNavigationBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
public function test_helpdesk_dashboard_and_main_pages_render_with_professional_headers(): void
{
$this->seedLookupData();
$user = $this->admin(['name' => 'Mark Gregory']);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.index')
->waitFor('@helpdesk-dashboard-widget')
->assertSee('Helpdesk dashboard')
->assertSee('Total tickets')
->assertSee('Open tickets')
->assertSee('Closed tickets')
->visitRoute('helpdesk.tickets.index')
->waitFor('@helpdesk-ticket-index')
->assertSee('Active tickets')
->visitRoute('helpdesk.tickets.create')
->waitFor('.helpdesk-ticket-form')
->assertSee('Create ticket')
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSourceHas('Statuses')
->assertSourceHas('Priorities')
->assertSourceHas('Projects')
->assertSourceHas('Workflow');
});
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskSettingsBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
public function test_settings_tabs_expose_lookup_git_and_workflow_setup_areas(): void
{
$this->seedLookupData();
$admin = $this->admin(['name' => 'Helpdesk Settings Admin']);
$this->browse(function (Browser $browser) use ($admin): void {
$browser->loginAs($admin)
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSourceMissing("route('helpdesk.tickets.index')")
->assertSourceMissing("route('helpdesk.tickets.create')")
->assertSourceMissing("route('helpdesk.kanban')")
->assertDontSee('+ New Ticket')
->assertDontSee('Kanban');
$this->openSettingsSection($browser, 'statuses', 'Statuses');
$this->openSettingsSection($browser, 'priorities', 'Priorities');
$this->openSettingsSection($browser, 'categories', 'Categories');
$this->openSettingsSection($browser, 'teams', 'Teams');
$this->openSettingsSection($browser, 'projects', 'Projects');
$this->openSettingsSection($browser, 'git', 'Remote URL');
$this->openSettingsSection($browser, 'workflow', 'HTML email body');
});
}
private function openSettingsSection(Browser $browser, string $section, string $expectedHeading): Browser
{
return $browser
->click('@helpdesk-settings-section-' . $section)
->waitForText($expectedHeading, 10);
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskTicketBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
public function test_user_can_create_and_view_ticket_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Ticket Browser User']);
$subject = 'Browser created helpdesk ticket ' . uniqid();
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->type('@ticket-subject', $subject);
$this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves the form saves correctly.',
);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$browser->waitFor('@ticket-create-submit', 10)
->press('@ticket-create-submit')
->waitUntilMissing('@helpdesk-ticket-create', 15)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
$ticket = Ticket::query()
->where('subject', $subject)
->latest('id')
->firstOrFail();
$browser->visitRoute('helpdesk.tickets.show', $ticket)
->waitFor('@helpdesk-ticket-show', 15)
->assertSee($subject)
->assertSee('This browser ticket proves the form saves correctly.');
});
}
public function test_user_can_add_nested_response_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Response Browser User']);
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $fixtures['open']->id,
'board_id' => $fixtures['board']->id,
'priority_id' => $fixtures['normal']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
'subject' => 'Browser response ticket',
]);
$parent = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'parent_id' => null,
'title' => 'Browser canned response',
'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for ' . $ticket->reference . '</p>',
'is_internal' => false,
]);
Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'parent_id' => $parent->id,
'title' => 'RE: Browser canned response',
'body' => 'Nested browser response',
'is_internal' => false,
]);
$this->browse(function (Browser $browser) use ($user, $ticket): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.show', ['ticket' => $ticket->getKey()])
->waitForText('Browser response ticket', 10)
->assertSee('Browser response ticket')
->assertDontSee('UNID')
->assertDontSee('GUID');
});
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for ' . $ticket->reference . '</p>',
]);
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => $parent->id,
'body' => 'Nested browser response',
]);
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
{
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
$encodedHtml = json_encode($html, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const key = {$encodedDusk};
const html = {$encodedHtml};
const root = document.querySelector('[dusk="' + key + '"]') || document.querySelector('[data-dusk="' + key + '"]');
if (! root) {
throw new Error('Helpdesk editor ' + key + ' was not found.');
}
const editor = root.matches('textarea, input, [contenteditable="true"]')
? root
: root.querySelector('textarea, input, [contenteditable="true"]');
if (! editor) {
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
}
editor.focus();
if (editor.matches('textarea, input')) {
editor.value = html.replace(/^<p>/, '').replace(/<\/p>$/, '');
} else {
editor.innerHTML = html;
}
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertText',
data: editor.value || editor.textContent || ' ',
}));
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
editor.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, composed: true }));
editor.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
const model = root.getAttribute('wire:model')
|| root.getAttribute('wire:model.live')
|| root.getAttribute('wire:model.defer')
|| root.getAttribute('data-wire-model');
const componentRoot = root.closest('[wire\\\\:id]');
if (model && componentRoot && window.Livewire) {
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (component) {
component.set(model, html);
}
}
JS);
}
private function setHelpdeskSelect(Browser $browser, string $model, int|string|null $value): void
{
$encodedModel = json_encode($model, JSON_THROW_ON_ERROR);
$encodedValue = json_encode($value === null ? '' : (string) $value, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const model = {$encodedModel};
const value = {$encodedValue};
const select = document.querySelector('[data-wire-model="' + model + '"]');
if (! select) {
throw new Error('Helpdesk select '+model+' was not found.');
}
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
if (window.jQuery) {
window.jQuery(select).val(value).trigger('change.select2').trigger('change.helpdesk-select2').trigger('change');
}
JS);
}
}
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Concerns;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Ticket;
trait BuildsHelpdeskFixtures
{
protected function user(array $attributes = []): User
{
/** @var User $user */
$user = User::factory()->create(array_merge([
'name' => 'Helpdesk User',
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
], $attributes));
return $user;
}
protected function agent(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true])->save();
return $user;
}
protected function admin(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true, 'helpdesk_admin' => true])->save();
return $user;
}
protected function ticketStatus(array $attributes = []): Status
{
return Status::query()->create(array_merge([
'name' => 'Open',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue',
'sort_order' => 10,
'is_closed' => false,
], $attributes));
}
protected function closedStatus(array $attributes = []): Status
{
return $this->ticketStatus(array_merge([
'name' => 'Closed',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'green',
'sort_order' => 99,
'is_closed' => true,
], $attributes));
}
protected function priority(array $attributes = []): Priority
{
return Priority::query()->create(array_merge([
'name' => 'Normal',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue',
'sort_order' => 10,
], $attributes));
}
protected function category(array $attributes = []): Category
{
return Category::query()->create(array_merge([
'company_id' => null,
'name' => 'Support',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue',
'description' => 'Support requests',
], $attributes));
}
protected function team(array $attributes = []): Team
{
return Team::query()->create(array_merge([
'company_id' => null,
'name' => 'Support Team',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'indigo',
'description' => 'Primary support team',
], $attributes));
}
protected function project(array $attributes = []): Project
{
return Project::query()->create(array_merge([
'company_id' => null,
'team_id' => null,
'title' => 'WMS Project',
'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue',
'description' => 'Warehouse platform project',
], $attributes));
}
protected function repository(array $attributes = []): GitRepository
{
return GitRepository::query()->create(array_merge([
'company_id' => null,
'project_id' => null,
'name' => 'Core',
'provider' => 'git',
'remote_url' => 'git@example.com:wms/core.git',
'default_branch' => 'develop',
'webhook_secret' => 'test-secret',
'is_active' => true,
], $attributes));
}
protected function ticket(array $attributes = []): Ticket
{
$owner = $attributes['owner'] ?? $this->user(['name' => 'Requester']);
$agent = $attributes['agent'] ?? null;
unset($attributes['owner'], $attributes['agent']);
return Ticket::query()->create(array_merge([
'reference' => 'HD-' . now()->format('Y') . '-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT),
'user_id' => $owner->id,
'agent_id' => $agent?->id,
'status_id' => $this->ticketStatus()->id,
'board_id' => $attributes['board_id'] ?? null,
'priority_id' => $this->priority()->id,
'category_id' => null,
'project_id' => null,
'team_id' => null,
'subject' => 'Test ticket subject',
'body' => '<p>Detailed helpdesk ticket body.</p>',
'last_activity_at' => now(),
], $attributes));
}
protected function seedLookupData(): array
{
$open = Status::query()->updateOrCreate(
['slug' => 'open'],
[
'name' => 'Open',
'color' => 'blue',
'sort_order' => 10,
'is_closed' => false,
],
);
$closed = Status::query()->updateOrCreate(
['slug' => 'closed'],
[
'name' => 'Closed',
'color' => 'green',
'sort_order' => 99,
'is_closed' => true,
],
);
$normal = Priority::query()->updateOrCreate(
['slug' => 'normal'],
[
'name' => 'Normal',
'color' => 'blue',
'sort_order' => 10,
],
);
$high = Priority::query()->updateOrCreate(
['slug' => 'high'],
[
'name' => 'High',
'color' => 'red',
'sort_order' => 1,
],
);
$category = Category::query()->updateOrCreate(
['slug' => 'support'],
[
'company_id' => null,
'name' => 'Support',
'color' => 'blue',
'description' => 'Support requests',
],
);
$team = Team::query()->updateOrCreate(
['slug' => 'support-team'],
[
'company_id' => null,
'name' => 'Support Team',
'color' => 'indigo',
'description' => 'Primary support team',
],
);
$project = Project::query()->updateOrCreate(
['slug' => 'wms-core'],
[
'company_id' => null,
'team_id' => $team->id,
'title' => 'WMS Core',
'color' => 'blue',
'description' => 'Warehouse platform project',
],
);
$board = KanbanBoard::query()->updateOrCreate(
['slug' => 'standard-support'],
[
'name' => 'Standard Support',
'description' => 'Default support board',
'is_default' => true,
'sort_order' => 1,
],
);
$board->statuses()->syncWithoutDetaching([
$open->id => ['sort_order' => 10],
$closed->id => ['sort_order' => 20],
]);
return compact('open', 'closed', 'normal', 'high', 'category', 'team', 'project', 'board');
}
protected function ensureCompanyTableHasOne(): ?int
{
if (! Schema::hasTable('companies')) {
return null;
}
$columns = Schema::getColumnListing('companies');
$payload = [];
foreach ($columns as $column) {
if (in_array($column, ['id', 'created_at', 'updated_at', 'deleted_at'], true)) {
continue;
}
if ($column === 'name') {
$payload[$column] = 'MPBA Test Company';
}
}
if (! array_key_exists('name', $payload) && in_array('company_name', $columns, true)) {
$payload['company_name'] = 'MPBA Test Company';
}
return (int) DB::table('companies')->insertGetId($payload);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\AgentAssignmentService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class AgentAssignmentTest extends TestCase
{
use 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,133 @@
<?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,332 @@
<?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,268 @@
<?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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Modules\Company\Models\Company;
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',
]);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Modules\Helpdesk\Support\Color;
use Tests\TestCase;
final class ColorPaletteTest extends TestCase
{
/** @test */
public function palette_colours_resolve_to_standard_hex_values(): void
{
self::assertSame('#dc2626', Color::css('red'));
self::assertSame('#16a34a', Color::css('green'));
self::assertSame('#2563eb', Color::css('blue'));
self::assertSame('#6b7280', Color::css('gray'));
}
/** @test */
public function legacy_colour_aliases_are_normalised(): void
{
self::assertSame('red', Color::normalise('rose'));
self::assertSame('green', Color::normalise('emerald'));
self::assertSame('blue', Color::normalise('cyan'));
self::assertSame('gray', Color::normalise('zinc'));
}
/** @test */
public function custom_hex_colours_are_supported_and_validated(): void
{
self::assertSame('#ffffff', Color::css('#ffffff'));
self::assertSame('#000000', Color::css('#000000'));
self::assertTrue(Color::isAllowed('#ffffff'));
self::assertFalse(Color::isAllowed('#fff'));
self::assertFalse(Color::isAllowed('brown'));
}
/** @test */
public function readable_text_colour_is_calculated_for_badge_backgrounds(): void
{
self::assertSame('#111827', Color::text('#ffffff'));
self::assertSame('#ffffff', Color::text('#000000'));
self::assertSame('#ffffff', Color::text('red'));
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class ModelRelationshipTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_ticket_loads_company_project_team_category_priority_status_and_git_commits(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository(['project_id' => $fixtures['project']->id]);
$ticket = $this->ticket([
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
]);
GitCommit::query()->create([
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('a', 40),
'short_hash' => 'aaaaaaa',
'branch' => 'develop',
'author_name' => 'Developer',
'author_email' => 'dev@example.com',
'message' => 'Fix HD reference handling',
'url' => 'https://example.com/commit/a',
'committed_at' => now(),
]);
$ticket->refresh()->load(['status', 'priority', 'category', 'team', 'project', 'gitCommits']);
self::assertSame('Open', $ticket->status?->name);
self::assertSame('High', $ticket->priority?->name);
self::assertSame('Support', $ticket->category?->name);
self::assertSame('Support Team', $ticket->team?->name);
self::assertSame('WMS Core', $ticket->project?->title);
self::assertCount(1, $ticket->gitCommits);
}
public function test_comments_support_nested_replies(): void
{
$ticket = $this->ticket();
$user = $this->user();
$comment = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'body' => '<p>Parent response</p>',
'is_internal' => false,
]);
Comment::query()->create([
'ticket_id' => $ticket->id,
'parent_id' => $comment->id,
'user_id' => $user->id,
'body' => '<p>Nested response</p>',
'is_internal' => false,
]);
self::assertSame('Nested response', strip_tags((string) $comment->replies()->firstOrFail()->body));
self::assertCount(1, $ticket->comments()->with('replies')->get());
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\TicketReferenceService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketReferenceServiceTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_it_generates_the_first_reference_for_the_current_year(): void
{
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00001', $reference);
}
public function test_it_generates_the_next_reference_after_existing_tickets(): void
{
$this->ticket(['reference' => 'HD-' . now()->format('Y') . '-00041']);
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00042', $reference);
}
public function test_it_does_not_reuse_soft_deleted_ticket_references(): void
{
$year = now()->format('Y');
$ticket = $this->ticket(['reference' => 'HD-' . $year . '-00002']);
$ticket->delete();
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . $year . '-00003', $reference);
}
public function test_it_uses_the_highest_reference_suffix_instead_of_latest_database_id(): void
{
$year = now()->format('Y');
$this->ticket(['reference' => 'HD-' . $year . '-00042']);
$this->ticket(['reference' => 'HD-' . $year . '-00002']);
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . $year . '-00043', $reference);
}
}