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>
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal file
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal 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');
|
||||
});
|
||||
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal file
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal 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);
|
||||
});
|
||||
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal file
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal 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');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
146
src/tests/Feature/SnipeItClientTest.php
Normal file
146
src/tests/Feature/SnipeItClientTest.php
Normal 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.');
|
||||
});
|
||||
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal file
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal 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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user