- 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>
This commit is contained in:
2026-07-27 21:11:21 +02:00
parent 1df697afce
commit 7a8cf2037c
32 changed files with 1735 additions and 31 deletions

View File

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

View File

@@ -0,0 +1,106 @@
<?php
use App\Livewire\Admin\Panel;
use App\Models\Category;
use App\Models\Setting;
use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
function adminUserForSnipeitTest(): User
{
return User::query()->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');
});

View File

@@ -0,0 +1,68 @@
<?php
use App\Jobs\GenerateTicketAiSummaryJob;
use App\Models\User;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Bus;
function enableAiSummaryRegenerateOnMessage(bool $enabled): void
{
Settings::set('ai_enabled', '1');
Settings::set('ai_summary_enabled', '1');
Settings::set('ai_summary_regenerate_on_message', $enabled ? '1' : '0');
}
test('an operator reply dispatches an immediate summary regeneration when the setting is enabled', function () {
seedStatusesAndPriorities();
enableAiSummaryRegenerateOnMessage(true);
Bus::fake();
$ticket = makeTicket();
$operator = operatorUser();
app(TicketService::class)->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);
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Livewire\Operator\TicketShow;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
test('operator can manually trigger AI summary regeneration from the ticket sidebar', function () {
seedStatusesAndPriorities();
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');
Http::fake(['ai.test/*' => 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');
});

View File

@@ -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 <select> is bound via wire:change, not wire:model, so Livewire's
// morph step otherwise preserves whatever the browser already has
// selected instead of applying the freshly rendered "selected" option —
// a documented Livewire/Alpine-morph quirk for uncontrolled form
// elements. Keying the element to status_key forces a real replace.
$this->seed();
$operator = operatorUser('quickaction-wirekey@example.com');
$ticket = makeTicket(['number' => '1002', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml('wire:key="ticket-status-select-new"')
->set('reply', 'Naprawione, proszę potwierdzić.')
->call('sendAndTransition', $action->id)
->assertSeeHtml('wire:key="ticket-status-select-closed"');
});
test('sending via a "nie zmieniaj" quick action posts the reply without changing the status', function () {
seedStatusesAndPriorities();
$operator = operatorUser('quickaction-nochange@example.com');

View File

@@ -0,0 +1,146 @@
<?php
use App\Services\SnipeItClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableSnipeit(): void
{
Settings::set('snipeit_enabled', '1');
Settings::set('snipeit_base_url', 'https://assets.test');
Settings::set('snipeit_api_token', 'tok');
}
test('enabled requires enabled flag, base url and api token', function () {
expect(app(SnipeItClient::class)->enabled())->toBeFalse();
enableSnipeit();
expect(app(SnipeItClient::class)->enabled())->toBeTrue();
});
test('assetsForEmail looks up the Snipe-IT user by e-mail, then lists what is checked out to them', function () {
enableSnipeit();
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => [
['id' => 7, 'email' => 'jan@example.com', 'name' => 'Jan Kowalski'],
]]),
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
[
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'SN12345',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
],
]]),
]);
$assets = app(SnipeItClient::class)->assetsForEmail('jan@example.com');
expect($assets)->toHaveCount(1);
expect($assets[0]['id'])->toBe(100);
expect($assets[0]['label'])->toBe('SI-001 - SN12345 - Dell Latitude 5420');
expect($assets[0]['category'])->toBe('Laptopy');
expect($assets[0]['status'])->toBe('Deployed');
expect($assets[0]['url'])->toBe('https://assets.test/hardware/100');
});
test('normalizeAsset joins only whichever of asset tag / serial / manufacturer+model are present, falling back to the asset id', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/1' => Http::response([
'id' => 1, 'asset_tag' => 'SI-100', 'serial' => null, 'manufacturer' => null, 'model' => null,
])]);
expect(app(SnipeItClient::class)->asset(1)['label'])->toBe('SI-100');
Http::fake(['assets.test/api/v1/hardware/2' => Http::response([
'id' => 2, 'asset_tag' => null, 'serial' => null, 'manufacturer' => null, 'model' => null,
])]);
expect(app(SnipeItClient::class)->asset(2)['label'])->toBe('Zasób #2');
});
test('assetsForEmail returns nothing when no Snipe-IT user matches the e-mail', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/users?*' => Http::response(['rows' => []])]);
expect(app(SnipeItClient::class)->assetsForEmail('nobody@example.com'))->toBe([]);
});
test('assetsForEmail returns an empty list without an HTTP call when the integration is disabled', function () {
Http::fake();
expect(app(SnipeItClient::class)->assetsForEmail('jan@example.com'))->toBe([]);
Http::assertNothingSent();
});
test('searchAssets hits the hardware list endpoint with the search query', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => [
[
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
],
]])]);
$results = app(SnipeItClient::class)->searchAssets('drukarka');
expect($results)->toHaveCount(1);
expect($results[0]['label'])->toBe('SI-055 - HP LaserJet Pro');
expect($results[0]['category'])->toBe('Drukarki');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), 'search=drukarka'));
});
test('searchAssets returns nothing for a blank query without calling out', function () {
enableSnipeit();
Http::fake();
expect(app(SnipeItClient::class)->searchAssets(' '))->toBe([]);
Http::assertNothingSent();
});
test('asset fetches live detail including serial, category and current assignment', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/100' => Http::response([
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'ABC123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
'assigned_to' => ['name' => 'Jan Kowalski'],
])]);
$asset = app(SnipeItClient::class)->asset(100);
expect($asset['label'])->toBe('SI-001 - ABC123 - Dell Latitude 5420');
expect($asset['serial'])->toBe('ABC123');
expect($asset['category'])->toBe('Laptopy');
expect($asset['assignedTo'])->toBe('Jan Kowalski');
});
test('asset returns null when the asset no longer exists in Snipe-IT', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/999' => Http::response(['status' => 'error'], 404)]);
expect(app(SnipeItClient::class)->asset(999))->toBeNull();
});
test('testConnection reports ok on a successful response', function () {
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => []])]);
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'tok', true);
expect($result)->toBe(['ok' => true, 'message' => null]);
});
test('testConnection reports the API error message on failure', function () {
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['status' => 'error', 'messages' => 'Unauthenticated.'], 401)]);
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'bad-tok', true);
expect($result['ok'])->toBeFalse();
expect($result['message'])->toBe('Unauthenticated.');
});

View File

@@ -0,0 +1,241 @@
<?php
use App\Livewire\Client\NewTicket as ClientNewTicket;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Models\Category;
use App\Models\Ticket;
use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
function enableSnipeitForLinkingTest(array $overrides = []): void
{
Settings::set('snipeit_enabled', '1');
Settings::set('snipeit_base_url', 'https://assets.test');
Settings::set('snipeit_api_token', 'tok');
Settings::set('snipeit_client_can_select_asset', $overrides['client_can_select_asset'] ?? '1');
Settings::set('snipeit_operator_view_requester_assets', $overrides['operator_view_requester_assets'] ?? '1');
Settings::set('snipeit_operator_search_inventory', $overrides['operator_search_inventory'] ?? '1');
}
function fakeSnipeitUserAsset(string $email = 'client-snipeit@example.com'): void
{
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => [
['id' => 7, 'email' => $email, 'name' => 'Test Client'],
]]),
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
[
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
],
]]),
'assets.test/api/v1/hardware/100' => Http::response([
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
'assigned_to' => ['name' => 'Test Client'],
]),
]);
}
test('a client can link one of their own Snipe-IT assets while creating a ticket, shown as serial - manufacturer model / category', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('loadSnipeitAssets')
->assertSee('SI-001 - SN123 - Dell Latitude 5420')
->assertSee('Laptopy')
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->set('subject', 'Nie działa laptop')
->set('body', 'Opis problemu')
->call('submit');
$ticket = Ticket::query()->where('subject', 'Nie działa laptop')->firstOrFail();
expect($ticket->snipeit_asset_id)->toBe(100);
expect($ticket->snipeit_asset_name)->toBe('SI-001 - SN123 - Dell Latitude 5420');
});
test('selecting the same asset twice deselects it', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest();
$email = 'client-snipeit2@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('loadSnipeitAssets')
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', null);
});
test('the client asset picker is empty when "klient może wybrać sprzęt" is off, even for an allow-listed subcategory', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit3@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest(['client_can_select_asset' => '0']);
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('loadSnipeitAssets')
->assertDontSee('SN123');
});
test('the client asset picker only shows for subcategories the admin allow-listed', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit4@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$allowedSub = $category->subcategories()->create(['name' => 'Laptop']);
$otherSub = $category->subcategories()->create(['name' => 'Telefon']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $allowedSub->id);
// Allow-listed subcategory: the picker shows.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $allowedSub->id)
->call('loadSnipeitAssets')
->assertSee('SI-001 - SN123 - Dell Latitude 5420');
// A subcategory not in the allow-list: the picker stays hidden.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $otherSub->id)
->call('loadSnipeitAssets')
->assertDontSee('SN123');
});
test('changing subcategory clears a previously selected asset', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit5@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->call('selectSubcategory', $sub->id)
->assertSet('selectedSnipeitAssetId', null);
});
test('an operator can link a Snipe-IT asset found via inventory search to an existing ticket', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest();
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => []]),
'assets.test/api/v1/hardware?*' => Http::response(['rows' => [
[
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
],
]]),
'assets.test/api/v1/hardware/55' => Http::response([
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
]),
]);
$operator = operatorUser();
$ticket = makeTicket();
$component = Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->set('snipeitSearchQuery', 'drukarka')
->call('searchSnipeitAssets')
->assertSee('SI-055 - HP LaserJet Pro')
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBe(55);
expect($ticket->snipeit_asset_name)->toBe('SI-055 - HP LaserJet Pro');
expect($ticket->histories()->latest()->first()->text)->toContain('Powiązano sprzęt');
// Unlinking works regardless of the two view/search toggles (see next test).
$component->call('unlinkSnipeitAsset');
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('linking is a no-op when neither requester-assets view nor inventory search is enabled for the operator', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
Http::fake(['assets.test/*' => Http::response(['rows' => []])]);
$operator = operatorUser();
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('unlinking stays available even when both operator view/search toggles are off', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
$operator = operatorUser();
$ticket = makeTicket(['snipeit_asset_id' => 100, 'snipeit_asset_name' => 'SI-001 - SN123 - Dell Latitude 5420']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('unlinkSnipeitAsset');
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('an operator cannot link an asset found via search when inventory search is disabled, even if the id is valid', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_search_inventory' => '0']);
Http::fake(['assets.test/api/v1/hardware/55' => Http::response([
'id' => 55, 'asset_tag' => 'SI-055', 'serial' => null, 'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
])]);
$operator = operatorUser();
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->set('snipeitSearchResults', [['id' => 55, 'label' => 'HP LaserJet Pro']])
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});

View File

@@ -110,6 +110,50 @@ test('a malformed AI response leaves the previous summary untouched and keeps th
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');