Merge branch 'stage'

# Conflicts:
#	Modules/Helpdesk/Resources/views/components/html-editor.blade.php
#	Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php
#	Modules/Helpdesk/changelog.yaml
#	Modules/Helpdesk/module.json
This commit is contained in:
Mark Gregory
2026-06-25 16:53:58 +01:00
110 changed files with 5391 additions and 714 deletions
@@ -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);
}
}
@@ -76,6 +76,41 @@ final class GitAndAttachmentTest extends TestCase
]);
}
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');
@@ -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);
}
}
@@ -160,4 +160,174 @@ final class GitWebhookIngestionTest extends TestCase
'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,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,78 @@
<?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-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,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,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);
}
}
@@ -9,24 +9,53 @@ use Tests\TestCase;
final class TicketCreateEditorSyncFilesTest extends TestCase
{
/**
* The ticket create editor must bind the details body through Livewire
* without eager live validation while the user is typing.
* 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_pushes_details_body_to_livewire_before_validation(): void
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('wire:model="body"', $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);
$this->assertStringContainsString('submit', $view);
$this->assertTrue(
str_contains($view, 'Livewire')
|| str_contains($view, '$wire')
|| str_contains($view, '@this')
|| str_contains($view, 'wire:model="body"'),
'Ticket create editor must keep the body field synchronised with Livewire before validation.'
);
}
/**
* 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);
}
/**
@@ -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,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,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);
}
}
}