major helpdesk updates

This commit is contained in:
Mark Gregory
2026-06-15 13:47:58 +01:00
parent 330d6922a4
commit b668116b11
47 changed files with 2350 additions and 23 deletions
@@ -31,12 +31,36 @@ final class HelpdeskSettingsBrowserTest extends DuskTestCase
->assertSee('Projects')
->assertSee('Git')
->assertSee('Workflow')
->assertSee('Canned')
->assertSee('SLA')
->assertSee('Rules')
->assertSee('Checks')
->assertSee('Tags')
->assertSee('Products')
->press('Git')
->waitForText('Git repositories')
->assertSee('Remote URL')
->press('Workflow')
->waitForText('Workflow messages')
->assertSee('HTML email body');
->assertSee('HTML email body')
->press('Canned')
->waitForText('Canned responses')
->assertSee('Reply body')
->press('SLA')
->waitForText('SLA policies')
->assertSee('First response minutes')
->press('Rules')
->waitForText('Email automation rules')
->assertSee('Subject contains')
->press('Checks')
->waitForText('Checklist templates')
->assertSee('Checklist items')
->press('Tags')
->waitForText('Ticket tags')
->assertSee('Colour')
->press('Products')
->waitForText('Products and services')
->assertSee('Release/build');
});
}
}
@@ -0,0 +1,163 @@
<?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,
]);
}
}
@@ -0,0 +1,138 @@
<?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\Ticket;
use Modules\Helpdesk\Models\TicketSlaEvent;
use Modules\Helpdesk\Services\AutomationRuleService;
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);
}
}