From 7a8cf2037cfd63cc7f441e3db890cd4fc89cea91 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 27 Jul 2026 21:11:21 +0200 Subject: [PATCH] v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARCHITECTURE.md | 110 +++++++- CHANGELOG.md | 48 ++++ README.md | 27 +- install.md | 4 +- src/.env.example | 2 +- src/app/Jobs/GenerateTicketAiSummaryJob.php | 30 +++ src/app/Livewire/Admin/Panel.php | 70 +++++ src/app/Livewire/Client/NewTicket.php | 64 +++++ src/app/Livewire/Operator/TicketShow.php | 121 +++++++++ src/app/Models/Ticket.php | 2 + src/app/Providers/AppServiceProvider.php | 22 ++ src/app/Services/SnipeItClient.php | 212 +++++++++++++++ src/app/Services/TicketAiSummaryService.php | 29 ++- src/app/Services/TicketService.php | 23 ++ src/app/Support/Settings.php | 11 +- ...158_add_snipeit_asset_to_tickets_table.php | 29 +++ .../views/components/snipeit-assets.blade.php | 64 +++++ .../views/livewire/admin/panel.blade.php | 62 ++++- .../livewire/client/new-ticket.blade.php | 9 + .../livewire/client/ticket-show.blade.php | 10 + .../livewire/operator/ticket-show.blade.php | 70 ++++- src/tests/Feature/AdminAiTriageConfigTest.php | 11 + .../AdminSnipeitIntegrationConfigTest.php | 106 ++++++++ .../AiSummaryRegenerateOnMessageTest.php | 68 +++++ .../OperatorAiSummaryRegenerateTest.php | 43 ++++ src/tests/Feature/ReplyQuickActionsTest.php | 18 ++ src/tests/Feature/SnipeItClientTest.php | 146 +++++++++++ src/tests/Feature/SnipeitAssetLinkingTest.php | 241 ++++++++++++++++++ .../Feature/TicketAiSummaryServiceTest.php | 44 ++++ wiki/admin/README.md | 40 ++- wiki/client/README.md | 12 +- wiki/operator/README.md | 18 +- 32 files changed, 1735 insertions(+), 31 deletions(-) create mode 100644 src/app/Jobs/GenerateTicketAiSummaryJob.php create mode 100644 src/app/Services/SnipeItClient.php create mode 100644 src/database/migrations/2026_07_27_000158_add_snipeit_asset_to_tickets_table.php create mode 100644 src/resources/views/components/snipeit-assets.blade.php create mode 100644 src/tests/Feature/AdminSnipeitIntegrationConfigTest.php create mode 100644 src/tests/Feature/AiSummaryRegenerateOnMessageTest.php create mode 100644 src/tests/Feature/OperatorAiSummaryRegenerateTest.php create mode 100644 src/tests/Feature/SnipeItClientTest.php create mode 100644 src/tests/Feature/SnipeitAssetLinkingTest.php 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