Files
servicedesk/src/tests/Feature/TicketAiSummaryServiceTest.php
Kacper 7a8cf2037c v1.3.0
- Snipe-IT asset inventory integration (Admin > Integracje), optional and off
  by default: connect by API address + personal token (+ SSL-verification
  bypass). Three independent toggles: client can pick which of their own
  Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories,
  empty = never shows), operator sees the requester's assets in a ticket-view
  sidebar, operator can search the whole inventory from that same sidebar (not
  a separate page) for shared equipment. Assets shown as "numer środka - numer
  seryjny - producent model" + category; a linked asset's live status is
  fetched fresh on the ticket page, and unlinking stays available to an
  operator even with both view/search toggles off.
- AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh,
  plus a new admin toggle to regenerate right after every new reply/note
  instead of only on the next scheduled sweep. The transcript sent to the
  model now also includes the ticket's own opening body, fixing summaries
  missing the original request on long threads.
- Fixed: the status dropdown in the operator ticket view could keep showing
  the pre-change status after a status-changing quick action until the next
  page load (Livewire/Alpine-morph quirk for wire:change-bound selects).
- Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the
  above, including correcting the AI-summary refresh description left over
  from the 1.2.1 release notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:11:21 +02:00

171 lines
6.7 KiB
PHP

<?php
use App\Models\Ticket;
use App\Services\TicketAiSummaryService;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableAiSummary(): void
{
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_summary_enabled', '1');
}
function summaryTicket(array $overrides = []): Ticket
{
return Ticket::query()->create(array_merge([
'number' => (string) random_int(100000, 999999),
'email' => 'client@example.com',
'name' => 'Test Client',
'subject' => 'Problem z drukarką',
'body' => 'Drukarka nie działa od rana.',
'status_key' => 'new',
'priority_key' => 'medium',
'custom_fields' => [],
], $overrides));
}
function fakeAiSummaryChat(string $content): void
{
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
}
test('generates and stores a summary + suggested action for a fresh ticket', function () {
enableAiSummary();
fakeAiSummaryChat('{"summary": "Klient zgłasza awarię drukarki.", "suggested_action": "Poproś o model drukarki."}');
$ticket = summaryTicket();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
$ticket->refresh();
expect($ticket->ai_summary)->toBe('Klient zgłasza awarię drukarki.');
expect($ticket->ai_suggested_action)->toBe('Poproś o model drukarki.');
expect($ticket->ai_summary_generated_at)->not->toBeNull();
});
test('a ticket with a newer message than its last summary is picked up again', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Pierwsza wiadomość.']);
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => now()->subDay()]);
// A message created "now" postdates the day-old summary.
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Nowa odpowiedź operatora.']);
fakeAiSummaryChat('{"summary": "Zaktualizowane podsumowanie.", "suggested_action": null}');
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
expect($ticket->refresh()->ai_summary)->toBe('Zaktualizowane podsumowanie.');
});
test('a ticket whose summary is already newer than its latest message is not reprocessed', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});
test('an unrelated update() that only touches updated_at does not trigger re-summarization', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
// Simulates e.g. a priority/status change touching tickets.updated_at
// without any new ticket_messages row.
$ticket->update(['priority_key' => 'high']);
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});
test('a malformed AI response leaves the previous summary untouched and keeps the ticket stale', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => null]);
fakeAiSummaryChat('to nie jest JSON');
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 0, 'failed' => 1]);
$ticket->refresh();
expect($ticket->ai_summary)->toBe('stare podsumowanie');
expect($ticket->ai_summary_generated_at)->toBeNull();
});
test('the transcript sent to the AI includes the ticket subject, its own body, and every message tagged by role', function () {
enableAiSummary();
$ticket = summaryTicket(['subject' => 'Problem z drukarką', 'body' => 'Drukarka nie działa od rana.']);
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Dodatkowy szczegół.'])->attachAuthor(null, 'client');
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Sprawdzam sprawę.'])->attachAuthor(null, 'operator');
fakeAiSummaryChat('{"summary": "ok", "suggested_action": null}');
app(TicketAiSummaryService::class)->run();
Http::assertSent(function ($request) {
$userMessage = collect($request->data()['messages'])->firstWhere('role', 'user')['content'];
return str_contains($userMessage, 'Temat: Problem z drukarką')
&& str_contains($userMessage, 'Treść:')
&& str_contains($userMessage, 'Drukarka nie działa od rana.')
&& str_contains($userMessage, '[klient] Test Client: Dodatkowy szczegół.')
&& str_contains($userMessage, '[operator] Operator: Sprawdzam sprawę.');
});
});
test('generateFor() regenerates a single ticket immediately, ignoring the staleness check', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
fakeAiSummaryChat('{"summary": "Świeże podsumowanie.", "suggested_action": null}');
$result = app(TicketAiSummaryService::class)->generateFor($ticket);
expect($result)->toBeTrue();
expect($ticket->refresh()->ai_summary)->toBe('Świeże podsumowanie.');
});
test('generateFor() returns false without calling the AI when the integration is disabled', function () {
Settings::set('ai_enabled', '0');
$ticket = summaryTicket();
Http::fake();
expect(app(TicketAiSummaryService::class)->generateFor($ticket))->toBeFalse();
Http::assertNothingSent();
});
test('ai_summary_enabled=0 makes no AI calls', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_summary_enabled', '0');
summaryTicket();
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});