diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e49064..3497e02 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -470,9 +470,10 @@ layered on top rather than baked into the client itself. ## BookStack integration -`App\Services\BookStackClient` is one of two outbound HTTP clients in the -codebase (Laravel's `Http` facade), alongside `AiClient` above — everything -else here only ever receives requests. It's entirely `Settings`-driven, no +`App\Services\BookStackClient` is one of three outbound HTTP clients in the +codebase (Laravel's `Http` facade), alongside `AiClient` above and +`SnipeItClient` below — everything else here only ever receives requests. +It's entirely `Settings`-driven, no `.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent** @@ -527,6 +528,70 @@ unless `--force`/the "wszystko ponownie" button is used — and new tags are merged into an item's existing tags (`updateTags()` PUTs the whole array; BookStack has no "append a tag" endpoint), never overwriting unrelated ones. +## Snipe-IT asset inventory integration + +`App\Services\SnipeItClient` talks to a Snipe-IT instance's REST API +(`/api/v1/...`, bearer token auth), entirely `Settings`-driven like +`BookStackClient`: `snipeit_enabled`, `snipeit_base_url`, +`snipeit_api_token` (encrypted), `snipeit_verify_ssl`. Every call is wrapped +in `try/catch(\Throwable)` returning `[]`/`null` on failure, same +safe-default convention as `AiClient`/`BookStackClient`. Three independently +toggleable settings gate what a client/operator can actually do with it — +none of them affect `SnipeItClient` itself, only which Livewire methods are +willing to call it: + +- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`, + a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker. + Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means + the picker never shows for any subcategory, not "every subcategory" — + `NewTicket::snipeitAssets()` checks both the toggle and that the currently + selected `subcategoryId` is in the list before calling + `assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any + already-picked asset, so switching to an out-of-scope subcategory can't + silently carry a stale selection through to `submit()`. +- `snipeit_operator_view_requester_assets` — gates the same + `assetsForEmail()` lookup (by the ticket's own `email`, not the viewing + operator's) in `Operator\TicketShow`'s sidebar. +- `snipeit_operator_search_inventory` — gates `searchAssets()`, a free-text + `/hardware?search=` lookup across the *whole* inventory, for linking + equipment the requester doesn't personally own (e.g. a shared printer). + Rendered inline in the same sidebar card as the requester-assets list, not + a separate route/page. + +`Operator\TicketShow::linkSnipeitAsset(int $id)` deliberately does **not** +fall back to a direct `SnipeItClient::asset($id)` lookup by id — it only +accepts an id present in `snipeitRequesterAssets`/`snipeitSearchResults`, +and each of those is itself empty unless its own setting above is on. This +means an operator can't link an arbitrary asset through a source the admin +has switched off for them, even by tampering with the Livewire request +payload. `unlinkSnipeitAsset()` has no such gate — clearing an existing link +is a correction, not a new way to browse Snipe-IT, so it stays available +even with both toggles off. + +`SnipeItClient::assetsForEmail()` has to resolve an e-mail to a Snipe-IT user +first (`GET /users?search=`, no "assets by e-mail" endpoint exists), then +lists what's checked out to them (`GET /users/{id}/assets`) — cached 5 +minutes per e-mail. `normalizeAsset()` is the single place that turns a raw +Snipe-IT hardware row into the shape every caller/view uses (`id`, `label`, +`serial`, `manufacturer`, `model`, `category`, `status`, `url`); `label` +joins whichever of asset tag / serial / "manufacturer model" are actually +present with `" - "`, falling back to `Zasób #{id}` if all three are blank — +Snipe-IT doesn't guarantee any of them are filled in. The `x-snipeit-assets` +Blade component renders that shape everywhere an asset list shows up +(client picker, requester sidebar, search results), with a `card` prop that +skips its own wrapping `
` when embedded inside a +caller-provided one (the inventory-search box + its results share one card). + +A linked ticket only stores `tickets.snipeit_asset_id` + a cached +`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also +writes a ticket-history line) — no other Snipe-IT fields are persisted. +Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card), +it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted +from the cache, so a status/reassignment change made directly in Snipe-IT is +reflected immediately; the cached label is only ever the fallback shown when +that live fetch fails (instance unreachable, or the asset was deleted +there). + ## AI ticket triage & summary Two independent services, both consuming `AiClient` above, both run from a @@ -565,17 +630,20 @@ live customer submitting a ticket: toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/ `ai_summary_generated_at` and shown only in the operator ticket view (a "Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the - BookStack suggestions card next to it — never live-called from the ticket - page itself, only ever displaying whatever the scheduled command last - computed). Regenerates whenever a ticket's latest message postdates its - last summary — deliberately compared against `ticket_messages.created_at`, - not `tickets.updated_at` (which also changes on unrelated actions like a + BookStack suggestions card next to it). `run()` (the scheduled sweep) + regenerates whenever a ticket's latest message postdates its last summary + — deliberately compared against `ticket_messages.created_at`, not + `tickets.updated_at` (which also changes on unrelated actions like a status/priority edit, which would otherwise trigger spurious - re-summarization on every tick for an active ticket). Unlike the triage - service, a malformed AI response here leaves the previous summary - untouched rather than stamping "done" — the ticket stays in the "stale" - set and gets retried next run, since this feature is meant to keep - refreshing indefinitely, not run once. The system prompt is + re-summarization on every tick for an active ticket). `buildTranscript()` + includes the ticket's own `body` (the opening description, outside + `ticket_messages`) ahead of the message transcript — needed because that + row would otherwise fall outside `TRANSCRIPT_MESSAGE_LIMIT` (30) on any + thread longer than that, silently dropping the original request from the + prompt. Unlike the triage service, a malformed AI response here leaves the + previous summary untouched rather than stamping "done" — the ticket stays + in the "stale" set and gets retried next run, since this feature is meant + to keep refreshing indefinitely, not run once. The system prompt is admin-editable (`ai_summary_prompt` setting, plain textarea with a "Resetuj" button restoring `Settings::default('ai_summary_prompt')` — same pattern as the e-mail footer editor) and asks the model for a small @@ -583,6 +651,22 @@ live customer submitting a ticket: the same defensive regex-extract-then-decode approach used throughout these AI services. +Besides `run()`'s scheduled sweep, two paths call `generateFor(Ticket +$ticket): bool` directly, bypassing the staleness check entirely: +`Operator\TicketShow::regenerateAiSummary()` (the sidebar's "Wygeneruj +teraz" button, a synchronous Livewire call — its `wire:loading` state covers +the wait, no need to dispatch anything in the background) and a +`TicketMessagePosted` listener registered in +`AppServiceProvider::regenerateAiSummaryOnNewMessage()`, active only when +both `ai_summary_enabled` and `ai_summary_regenerate_on_message` (off by +default) are on. That listener dispatches `App\Jobs\GenerateTicketAiSummaryJob` +via `::dispatchAfterResponse()` rather than the normal queue — deliberately +**not** `ShouldQueue`, since this deployment's queue worker is optional +infrastructure (see install.md) and anything pushed onto the `jobs` table +has no guarantee of ever being picked up; `dispatchAfterResponse()` instead +runs the job in-process right after the triggering HTTP/console response is +sent, needing no worker at all. + Its own interval (`ai:run-ticket-automation`) is admin-configurable the same way the other 3 scheduled commands are — see "Configurable scheduled-command intervals" above for the mechanism and a boot-time trap worth knowing about diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab787d..c979df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.3.0] - 2026-07-27 + +### Added + +- **Snipe-IT asset inventory integration** (Admin > Integracje), optional and + off by default — connects to a Snipe-IT instance by API address + personal + API token (plus a "Nie sprawdzaj SSL" toggle for self-signed instances) and + surfaces three independently switchable capabilities: + - **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — while + creating a ticket, a client sees the devices checked out to them in + Snipe-IT (matched by e-mail) and can pick the one the ticket is about. + Scoped to admin-selected subcategories via a multi-select picker that + only appears once this is turned on — same "nothing shows until + explicitly opted in" convention as BookStack's shelf allow-lists. + - **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — the + same per-requester asset list, shown in a sidebar card on the ticket + view, with a "Powiąż" button per item. + - **Zezwól operatorowi na przeszukiwanie całego inwentarza** — a search + box + button in the same sidebar (not a separate page) letting an + operator link any asset in Snipe-IT, not just the requester's own — for + shared equipment like printers. + - A linked asset shows live status/category/current assignment (fetched + fresh from Snipe-IT, not just the cached label) with an "Odepnij" button + that stays available to the operator regardless of the two toggles above + — clearing an existing link is a correction, not new Snipe-IT access. + Every asset is displayed as "numer środka - numer seryjny - producent + model" plus its Snipe-IT category, joining whichever of those pieces are + actually present. +- **AI summary: manual regenerate + regenerate on new message.** A + "Wygeneruj teraz" button now sits on the operator's "Podsumowanie AI" card + for an immediate, on-demand refresh. Separately, a new admin toggle + ("Regeneruj podsumowanie od razu po każdej nowej wiadomości", off by + default) re-runs the summary right after any reply/note lands on a ticket, + instead of only ever picking it up on the next scheduled + `ai:run-ticket-automation` sweep. The transcript sent to the model now also + includes the ticket's own opening body text (previously only the reply + thread), fixing summaries silently missing the original request on long + tickets whose first message had scrolled out of the transcript window. + +### Fixed + +- The status dropdown in the operator ticket view could keep showing the + pre-change status after sending a reply via a status-changing quick action + (e.g. "Wyślij i oznacz jako rozwiązane") until the next full page load — a + Livewire/Alpine-morph quirk for `Włącz integrację z Snipe-IT + + @if ($snipeitConfig['enabled']) +
+
+

Osobisty token API generuje się w Snipe-IT: profil użytkownika → „Create New Token”.

+ + + Zaznacz tylko, jeśli instancja Snipe-IT korzysta z certyfikatu self-signed / z prywatnego CA. + +
+ +
+ + +

Przy tworzeniu zgłoszenia klient zobaczy listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i będzie mógł je powiązać ze zgłoszeniem.

+
+ + @if ($snipeitConfig['clientCanSelectAsset']) +
+ + +

Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.

+
+ @endif + +
+ +
+ + + +

Obie funkcje pojawiają się w bocznym panelu widoku zgłoszenia operatora — przeszukiwanie inwentarza jako pole wyszukiwania z przyciskiem „Szukaj”, nie osobna podstrona. Odpięcie już powiązanego urządzenia jest zawsze dostępne dla operatora, niezależnie od tych dwóch ustawień.

+
+ +
+ + + @if ($snipeitTestResult === 'ok') +
check_circlePołączenie OK
+ @elseif ($snipeitTestResult === 'error') +
errorBłąd połączenia{{ $snipeitTestMessage ? ': '.$snipeitTestMessage : '' }}
+ @endif +
+ @else + + @endif + +

Integracja AI

Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI — Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack. @@ -881,7 +938,10 @@ $tabGroups = [
-

Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.

+

Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Domyślnie odświeżane cyklicznie (co kilka minut, wraz z pozostałą automatyzacją AI powyżej).

+ + +

Zamiast czekać na najbliższy cykl automatyzacji — dotyczy odpowiedzi operatora, klienta i notatek wewnętrznych.

diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php index f424e20..5cfcc32 100644 --- a/src/resources/views/livewire/client/new-ticket.blade.php +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -64,6 +64,15 @@
+
+ +
+
diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index 2b9ca11..ea57404 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -119,6 +119,16 @@
+ @if ($ticket->snipeit_asset_name) +
+
Powiązany sprzęt
+
+ devices + {{ $ticket->snipeit_asset_name }} +
+
+ @endif +
Status i priorytet
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index 7cc3735..c0a828a 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -291,7 +291,7 @@
Status i przypisanie
- @foreach ($this->statuses as $s) @endforeach @@ -330,13 +330,77 @@
+ @if (\App\Support\Settings::bool('snipeit_enabled')) + @if ($ticket->snipeit_asset_id) + @php $linkedAsset = $this->snipeitLinkedAsset; @endphp + + @endif + + @if (\App\Support\Settings::bool('snipeit_operator_view_requester_assets')) +
+ +
+ @endif + + @if (\App\Support\Settings::bool('snipeit_operator_search_inventory')) +
+
Przeszukaj inwentarz
+
+ + +
+ + +
+ @endif + @endif +
@if (\App\Support\Settings::bool('ai_summary_enabled'))
-
Podsumowanie AI
+
+
Podsumowanie AI
+ +
+ @if ($aiSummaryRegenerateError) +
{{ $aiSummaryRegenerateError }}
+ @endif @if ($aiSummaryLoaded) @if ($ticket->ai_summary)
{{ $ticket->ai_summary }}
@@ -347,7 +411,7 @@ @endif
Zaktualizowano: {{ $ticket->ai_summary_generated_at?->diffForHumans() }}
@else -

Podsumowanie pojawi się po najbliższym cyklu automatyzacji AI.

+

Podsumowanie pojawi się po najbliższym cyklu automatyzacji AI, albo od razu po kliknięciu „Wygeneruj teraz”.

@endif @endif
diff --git a/src/tests/Feature/AdminAiTriageConfigTest.php b/src/tests/Feature/AdminAiTriageConfigTest.php index 8491254..f89ef95 100644 --- a/src/tests/Feature/AdminAiTriageConfigTest.php +++ b/src/tests/Feature/AdminAiTriageConfigTest.php @@ -47,6 +47,17 @@ test('unchecking every toggle and saving turns them all back off', function () { expect(Settings::bool('ai_summary_enabled'))->toBeFalse(); }); +test('saving the triage config also persists the regenerate-on-message toggle', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('aiSummaryRegenerateOnMessage', true) + ->call('saveAiTriageConfig') + ->assertOk(); + + expect(Settings::bool('ai_summary_regenerate_on_message'))->toBeTrue(); +}); + test('saveAiSummaryPrompt persists custom prompt text', function () { $admin = adminUser(); diff --git a/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php b/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php new file mode 100644 index 0000000..1e281e7 --- /dev/null +++ b/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php @@ -0,0 +1,106 @@ +create(['name' => 'Admin', 'email' => 'admin-snipeit@example.com', 'roles' => ['admin']]); +} + +test('the Integracje tab shows the Snipe-IT card with the requested fields', function () { + $admin = adminUserForSnipeitTest(); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->assertSee('Snipe-IT') + ->assertSee('Adres API') + ->assertSee('Klucz API') + ->assertSee('Nie sprawdzaj SSL') + ->assertSee('Klient może wybrać sprzęt, którego dotyczy zgłoszenie') + ->assertSee('Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia') + ->assertSee('Zezwól operatorowi na przeszukiwanie całego inwentarza'); +}); + +test('the subcategory scope picker only appears once "klient może wybrać sprzęt" is checked', function () { + $admin = adminUserForSnipeitTest(); + $category = Category::query()->create(['name' => 'Sprzęt']); + $category->subcategories()->create(['name' => 'Laptop']); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->assertDontSee('Ogranicz do podkategorii') + ->set('snipeitConfig.clientCanSelectAsset', true) + ->assertSee('Ogranicz do podkategorii') + ->assertSee('Sprzęt / Laptop'); +}); + +test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () { + $admin = adminUserForSnipeitTest(); + $category = Category::query()->create(['name' => 'Sprzęt']); + $sub = $category->subcategories()->create(['name' => 'Laptop']); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->set('snipeitConfig.baseUrl', 'https://assets.firma.test') + ->set('snipeitConfig.apiToken', 'super-secret-token') + ->set('snipeitConfig.skipSslVerification', true) + ->set('snipeitConfig.clientCanSelectAsset', true) + ->call('toggleSnipeitClientSubcategory', $sub->id) + ->set('snipeitConfig.operatorViewRequesterAssets', true) + ->set('snipeitConfig.operatorSearchInventory', false) + ->call('saveSnipeitConfig') + ->assertOk(); + + expect(Settings::get('snipeit_enabled'))->toBe('1'); + expect(Settings::get('snipeit_base_url'))->toBe('https://assets.firma.test'); + expect(Settings::get('snipeit_api_token'))->toBe('super-secret-token'); + // "Nie sprawdzaj SSL" checked means verify_ssl is stored as off. + expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse(); + expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue(); + expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id); + expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue(); + expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse(); + + $stored = Setting::query()->where('key', 'snipeit_api_token')->value('value'); + expect($stored)->not->toBe('super-secret-token'); +}); + +test('leaving the api token field blank on save keeps the previously stored token', function () { + Settings::set('snipeit_api_token', 'already-stored-token'); + $admin = adminUserForSnipeitTest(); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->set('snipeitConfig.baseUrl', 'https://assets.firma.test') + ->call('saveSnipeitConfig') + ->assertOk(); + + expect(Settings::get('snipeit_api_token'))->toBe('already-stored-token'); +}); + +test('testSnipeitConnection reports the result of a live probe using unsaved form values', function () { + Http::fake(['assets.firma.test/*' => Http::response(['rows' => []])]); + $admin = adminUserForSnipeitTest(); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->set('snipeitConfig.baseUrl', 'https://assets.firma.test') + ->set('snipeitConfig.apiToken', 'tok') + ->call('testSnipeitConnection') + ->assertSet('snipeitTestResult', 'ok'); +}); + +test('testSnipeitConnection requires a base URL before probing', function () { + $admin = adminUserForSnipeitTest(); + + Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->call('testSnipeitConnection') + ->assertSet('snipeitTestResult', 'error'); +}); diff --git a/src/tests/Feature/AiSummaryRegenerateOnMessageTest.php b/src/tests/Feature/AiSummaryRegenerateOnMessageTest.php new file mode 100644 index 0000000..55b1ab9 --- /dev/null +++ b/src/tests/Feature/AiSummaryRegenerateOnMessageTest.php @@ -0,0 +1,68 @@ +operatorReply($ticket, $operator, 'Odpowiedź operatora.'); + + Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class); +}); + +test('a client reply does not dispatch regeneration when the setting is disabled', function () { + seedStatusesAndPriorities(); + enableAiSummaryRegenerateOnMessage(false); + Bus::fake(); + + $ticket = makeTicket(); + $client = User::factory()->create(); + + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź klienta.'); + + Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class); +}); + +test('an internal operator note also triggers regeneration, matching the transcript including internal notes', function () { + seedStatusesAndPriorities(); + enableAiSummaryRegenerateOnMessage(true); + Bus::fake(); + + $ticket = makeTicket(); + $operator = operatorUser(); + + app(TicketService::class)->operatorNote($ticket, $operator, 'Notatka wewnętrzna.'); + + Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class); +}); + +test('no regeneration is dispatched when the AI summary feature itself is off, even with the toggle on', function () { + seedStatusesAndPriorities(); + Settings::set('ai_enabled', '1'); + Settings::set('ai_summary_enabled', '0'); + Settings::set('ai_summary_regenerate_on_message', '1'); + Bus::fake(); + + $ticket = makeTicket(); + $operator = operatorUser(); + + app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora.'); + + Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class); +}); diff --git a/src/tests/Feature/OperatorAiSummaryRegenerateTest.php b/src/tests/Feature/OperatorAiSummaryRegenerateTest.php new file mode 100644 index 0000000..33502cd --- /dev/null +++ b/src/tests/Feature/OperatorAiSummaryRegenerateTest.php @@ -0,0 +1,43 @@ + Http::response([ + 'choices' => [['message' => ['content' => '{"summary": "Ręcznie wygenerowane.", "suggested_action": null}']]], + ])]); + + $operator = operatorUser(); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->call('regenerateAiSummary') + ->call('loadAiSummary') + ->assertOk() + ->assertSee('Ręcznie wygenerowane.'); + + expect($ticket->refresh()->ai_summary)->toBe('Ręcznie wygenerowane.'); +}); + +test('a failed manual regeneration shows an error and leaves the previous summary intact', function () { + seedStatusesAndPriorities(); + Settings::set('ai_enabled', '0'); + + $operator = operatorUser(); + $ticket = makeTicket(); + $ticket->update(['ai_summary' => 'stare podsumowanie']); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->call('regenerateAiSummary') + ->assertSet('aiSummaryRegenerateError', 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.'); + + expect($ticket->refresh()->ai_summary)->toBe('stare podsumowanie'); +}); diff --git a/src/tests/Feature/ReplyQuickActionsTest.php b/src/tests/Feature/ReplyQuickActionsTest.php index cd0b7f4..4761a9f 100644 --- a/src/tests/Feature/ReplyQuickActionsTest.php +++ b/src/tests/Feature/ReplyQuickActionsTest.php @@ -98,6 +98,24 @@ test('sending via a status-changing quick action updates the ticket status', fun ->and($ticket->fresh()->messages()->where('body', 'Naprawione, proszę potwierdzić.')->exists())->toBeTrue(); }); +test('sending via a status-changing quick action changes the status select\'s wire:key so the browser is forced to redraw it', function () { + // The