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(); });