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