v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI, or a self-hosted Ollama instance) configured by base URL, optional API key, model, and an SSL-verification toggle. Foundation for the two AI features below and anything else that wants an LLM call in the future. - BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons plus `php artisan bookstack:tag-content` (--dry-run/--force/--limit=N) tag every book/chapter/page with matching helpdesk subcategory names, idempotent by default. - BookStack search refinement: "Przeszukuj" is now three independent checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare subcategory name, matching what auto-tagging writes. - AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles auto-assign/correct category+subcategory, rewrite an unclear subject, and set priority from content, once per ticket in the background; every change is logged in the ticket's history. Separately, an AI summary + suggested action for every ticket, shown to operators only, with an admin-editable prompt. - Operators can now reassign a ticket to any team, not just one they belong to. - The auto-refresh countdown badges (ticket view, operator queue) are now clickable — fetch immediately and reset the countdown. - All 7 "cyclical" intervals (3 browser refresh countdowns, the notification bell poll, and the 4 background scheduled commands) are now configurable from Admin > Konfiguracja instead of fixed in code. - Fixed: an operator viewing a ticket that's deleted or moved outside their team scope mid-session is now redirected to the operator queue instead of hitting an error. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the Integracje tab shows the Integracja AI card', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSee('Integracja AI');
|
||||
});
|
||||
|
||||
test('saving the AI config persists settings and encrypts the api key at rest', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.apiKey', 'super-secret-key')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->set('aiConfig.verifySsl', true)
|
||||
->call('saveAiConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_base_url'))->toBe('https://api.groq.test/openai/v1');
|
||||
expect(Settings::get('ai_model'))->toBe('llama-3.3-70b-versatile');
|
||||
expect(Settings::get('ai_api_key'))->toBe('super-secret-key');
|
||||
|
||||
$stored = Setting::query()->where('key', 'ai_api_key')->value('value');
|
||||
expect($stored)->not->toBe('super-secret-key');
|
||||
});
|
||||
|
||||
test('leaving the api key field blank on save keeps the previously stored key', function () {
|
||||
Settings::set('ai_api_key', 'already-stored-key');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->call('saveAiConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_api_key'))->toBe('already-stored-key');
|
||||
});
|
||||
|
||||
test('testAiConnection reports the result of a live probe using unsaved form values', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.apiKey', 'key')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->call('testAiConnection')
|
||||
->assertSet('aiTestResult', 'ok');
|
||||
});
|
||||
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the Integracje tab shows the Automatyzacja AI dla zgłoszeń card', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSee('Automatyzacja AI dla zgłoszeń');
|
||||
});
|
||||
|
||||
test('saving the triage config persists all 5 toggles plus the summary toggle', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiTriageConfig.categoryWhenMissing', true)
|
||||
->set('aiTriageConfig.subcategoryWhenCategoryOnly', true)
|
||||
->set('aiTriageConfig.recheckCategorized', true)
|
||||
->set('aiTriageConfig.fixSubject', true)
|
||||
->set('aiTriageConfig.setPriority', true)
|
||||
->set('aiSummaryEnabled', true)
|
||||
->call('saveAiTriageConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::bool('ai_triage_category_when_missing'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_subcategory_when_category_only'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_recheck_categorized'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_fix_subject'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_set_priority'))->toBeTrue();
|
||||
expect(Settings::bool('ai_summary_enabled'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('unchecking every toggle and saving turns them all back off', function () {
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiTriageConfig.categoryWhenMissing', false)
|
||||
->set('aiSummaryEnabled', false)
|
||||
->call('saveAiTriageConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::bool('ai_triage_category_when_missing'))->toBeFalse();
|
||||
expect(Settings::bool('ai_summary_enabled'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('saveAiSummaryPrompt persists custom prompt text', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('saveAiSummaryPrompt', 'Mój niestandardowy prompt.')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe('Mój niestandardowy prompt.');
|
||||
});
|
||||
|
||||
test('resetAiSummaryPrompt restores the default prompt after it was customized', function () {
|
||||
$default = Settings::default('ai_summary_prompt');
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('saveAiSummaryPrompt', 'Coś zupełnie innego.')
|
||||
->assertSet('aiSummaryPrompt', 'Coś zupełnie innego.');
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe('Coś zupełnie innego.');
|
||||
|
||||
$component->call('resetAiSummaryPrompt')
|
||||
->assertSet('aiSummaryPrompt', $default);
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe($default);
|
||||
});
|
||||
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the tagging buttons are visible once BookStack is enabled', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('bookstackConfig.enabled', true)
|
||||
->assertSee('Otaguj nową treść')
|
||||
->assertSee('Otaguj wszystko ponownie');
|
||||
});
|
||||
|
||||
test('clicking the normal button runs a non-force tagging pass and shows the summary', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTagging')
|
||||
->assertSet('bookstackTagResult', ['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0])
|
||||
->assertSee('Przeskanowano 2, otagowano 1, pominięto 1');
|
||||
});
|
||||
|
||||
test('clicking the force button reclassifies already-tagged content too', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTaggingForce')
|
||||
->assertSet('bookstackTagResult', ['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
|
||||
});
|
||||
|
||||
test('running tagging without a configured AI integration shows a helpful error instead of a silent no-op', function () {
|
||||
enableBookstackAndAiForTagging();
|
||||
Settings::set('ai_enabled', '0');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTagging')
|
||||
->assertSet('bookstackTagResult', null)
|
||||
->assertSee('Włącz i skonfiguruj obie integracje');
|
||||
});
|
||||
66
src/tests/Feature/AiClientTest.php
Normal file
66
src/tests/Feature/AiClientTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Services\AiClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('enabled is false unless ai_enabled, ai_base_url and ai_model are all set — api key is optional', function () {
|
||||
expect(app(AiClient::class)->enabled())->toBeFalse();
|
||||
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
expect(app(AiClient::class)->enabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('chat returns the assistant message content on success', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
Http::fake([
|
||||
'api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'hello']]]]),
|
||||
]);
|
||||
|
||||
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBe('hello');
|
||||
});
|
||||
|
||||
test('chat returns null on a non-successful response instead of throwing', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
Http::fake(['api.groq.test/*' => Http::response(['error' => 'nope'], 500)]);
|
||||
|
||||
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBeNull();
|
||||
});
|
||||
|
||||
test('chat sends no Authorization header when no api key is configured (self-hosted Ollama style)', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'http://ollama.test/v1');
|
||||
Settings::set('ai_model', 'llama3');
|
||||
|
||||
Http::fake(['ollama.test/*' => Http::response(['choices' => [['message' => ['content' => 'ok']]]])]);
|
||||
|
||||
app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]);
|
||||
|
||||
Http::assertSent(fn ($request) => ! $request->hasHeader('Authorization'));
|
||||
});
|
||||
|
||||
test('testConnection reports ok on a successful response', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
|
||||
|
||||
$ok = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'llama3', true);
|
||||
|
||||
expect($ok)->toBe(['ok' => true, 'message' => null]);
|
||||
});
|
||||
|
||||
test('testConnection reports the provider error message on failure', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['error' => ['message' => 'bad model']], 400)]);
|
||||
|
||||
$error = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'bad-model', true);
|
||||
|
||||
expect($error['ok'])->toBeFalse();
|
||||
expect($error['message'])->toBe('bad model');
|
||||
});
|
||||
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal file
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\BookStackContentTagger;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function seedTaggerSubcategory(): void
|
||||
{
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$category->subcategories()->create(['name' => 'Drukarki i skanery']);
|
||||
}
|
||||
|
||||
function enableBookstackAndAiForTagging(): void
|
||||
{
|
||||
Settings::set('bookstack_enabled', '1');
|
||||
Settings::set('bookstack_base_url', 'https://wiki.test');
|
||||
Settings::set('bookstack_token_id', 'id');
|
||||
Settings::set('bookstack_token_secret', 'secret');
|
||||
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array> $pageTags page id => existing tags array
|
||||
*/
|
||||
function fakeTaggerBookstackAndAi(array $pageTags, string $aiContent): void
|
||||
{
|
||||
Http::fake([
|
||||
'wiki.test/api/books*' => Http::response(['data' => [], 'total' => 0]),
|
||||
'wiki.test/api/chapters*' => Http::response(['data' => [], 'total' => 0]),
|
||||
'wiki.test/api/pages*' => function ($request) use ($pageTags) {
|
||||
$path = parse_url($request->url(), PHP_URL_PATH);
|
||||
|
||||
if ($request->method() === 'GET' && Str::endsWith($path, '/api/pages')) {
|
||||
return Http::response([
|
||||
'data' => collect($pageTags)->keys()->map(fn ($id) => ['id' => $id, 'name' => "Page {$id}"])->values()->all(),
|
||||
'total' => count($pageTags),
|
||||
]);
|
||||
}
|
||||
|
||||
$id = (int) basename($path);
|
||||
|
||||
if ($request->method() === 'GET') {
|
||||
return Http::response([
|
||||
'id' => $id,
|
||||
'name' => "Page {$id}",
|
||||
'tags' => $pageTags[$id] ?? [],
|
||||
'markdown' => 'Treść o drukarkach i skanerach.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->method() === 'PUT') {
|
||||
return Http::response(['id' => $id]);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
},
|
||||
'ai.test/*' => Http::response(['choices' => [['message' => ['content' => $aiContent]]]]),
|
||||
]);
|
||||
}
|
||||
|
||||
test('normal run tags untagged content and skips already-tagged content', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0]);
|
||||
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT'
|
||||
&& str_contains((string) $r->url(), '/api/pages/10')
|
||||
&& collect($r->data()['tags'])->contains(fn ($t) => $t['name'] === 'Drukarki i skanery'));
|
||||
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
|
||||
|
||||
// the already-tagged page never even makes it into the AI prompt
|
||||
Http::assertSent(fn ($r) => str_contains((string) $r->url(), 'ai.test')
|
||||
? (str_contains($r['messages'][1]['content'], 'id=10') && ! str_contains($r['messages'][1]['content'], 'id=11'))
|
||||
: true);
|
||||
});
|
||||
|
||||
test('--dry-run classifies but never calls PUT', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => []],
|
||||
aiContent: '{"10": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run(dryRun: true);
|
||||
|
||||
expect($totals['tagged'])->toBe(1);
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
|
||||
});
|
||||
|
||||
test('--force re-classifies and re-writes already-tagged content', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run(force: true);
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
|
||||
});
|
||||
|
||||
test('tag matching is case-insensitive when deciding whether content is already tagged', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": []}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals['skipped'])->toBe(1);
|
||||
expect($totals['scanned'])->toBe(1);
|
||||
});
|
||||
|
||||
test('a malformed AI response fails only that batch, without writing any tags', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => []],
|
||||
aiContent: 'this is not json at all',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 1]);
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
|
||||
});
|
||||
|
||||
test('the tagger never touches bookshelves', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(pageTags: [], aiContent: '{}');
|
||||
|
||||
app(BookStackContentTagger::class)->run();
|
||||
|
||||
Http::assertNotSent(fn ($r) => str_contains((string) $r->url(), '/api/shelves'));
|
||||
});
|
||||
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal file
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Services\BookStackClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableBookstackForSearch(string $searchBy = 'both'): void
|
||||
{
|
||||
Settings::set('bookstack_enabled', '1');
|
||||
Settings::set('bookstack_base_url', 'https://wiki.test');
|
||||
Settings::set('bookstack_token_id', 'id');
|
||||
Settings::set('bookstack_token_secret', 'secret');
|
||||
Settings::set('bookstack_search_by', $searchBy);
|
||||
Settings::set('bookstack_allowed_shelf_ids_creation', '1');
|
||||
|
||||
Http::fake([
|
||||
'wiki.test/api/shelves/1' => Http::response(['books' => [['id' => 5]]]),
|
||||
'wiki.test/api/shelves*' => Http::response(['data' => [['id' => 1, 'name' => 'IT']]]),
|
||||
'wiki.test/api/search*' => Http::response(['data' => []]),
|
||||
]);
|
||||
}
|
||||
|
||||
test('with search_by=both, the tags-variant query uses tagQuery while the name-variant keeps the full query', function () {
|
||||
enableBookstackForSearch('both');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '{in_name:IT-Pomoc Drukarki i skanery}');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
|
||||
});
|
||||
|
||||
test('omitting tagQuery falls back to the full query for backward compatibility', function () {
|
||||
enableBookstackForSearch('tags');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[IT-Pomoc Drukarki i skanery]');
|
||||
});
|
||||
|
||||
test('search_by=tags alone sends only the tag-form query built from tagQuery, never the plain category+subcategory text', function () {
|
||||
enableBookstackForSearch('tags');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
|
||||
|
||||
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& str_contains($request['query'] ?? '', 'IT-Pomoc'));
|
||||
});
|
||||
@@ -91,7 +91,7 @@ test('a non-admin operator can still open a ticket outside their team if it is p
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('the team reassignment dropdown on a ticket only offers a non-admin operator their own teams', function () {
|
||||
test('the team reassignment dropdown on a ticket offers a non-admin operator every team, not just their own', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-5@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
@@ -101,9 +101,25 @@ test('the team reassignment dropdown on a ticket only offers a non-admin operato
|
||||
$ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]);
|
||||
|
||||
$teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->instance()->teams->pluck('name')->all();
|
||||
->instance()->teams->pluck('name')->sort()->values()->all();
|
||||
|
||||
expect($teamNames)->toBe(['Infrastruktura']);
|
||||
expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']);
|
||||
});
|
||||
|
||||
test('a non-admin operator can reassign a ticket to a team they do not belong to', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-7@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '5002', 'team_id' => $myTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $otherTeam->id)
|
||||
->assertOk();
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
|
||||
});
|
||||
|
||||
test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('an operator viewing a ticket is sent to the queue instead of erroring when it is deleted by someone else', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-1@example.com');
|
||||
$ticket = makeTicket(['number' => '6001', 'team_id' => null]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
Ticket::query()->whereKey($ticket->id)->delete();
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('an operator is sent to the queue when a live update moves the ticket to a team outside their scope', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-2@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->update(['team_id' => $otherTeam->id]);
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('an operator is not redirected by a live update for a ticket still personally assigned to them', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-3@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6003', 'team_id' => $myTeam->id, 'assignee_id' => $operator->id]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->update(['team_id' => $otherTeam->id]);
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('an operator who reassigns a ticket to a team outside their own scope is redirected immediately', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-4@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6004', 'team_id' => $myTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $otherTeam->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
|
||||
});
|
||||
|
||||
test('an operator who reassigns a ticket to their own team is not redirected', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-5@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6005', 'team_id' => null]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $myTeam->id)
|
||||
->assertNoRedirect();
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($myTeam->id);
|
||||
});
|
||||
|
||||
test('a direct link to a since-deleted ticket redirects an operator to their queue instead of a 404', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-6@example.com');
|
||||
$ticket = makeTicket(['number' => '6006']);
|
||||
$url = route('operator.ticket', $ticket);
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal file
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\NotificationBell;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator ticket view renders the configured interval and a working click-to-refresh handler', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('refresh_ticket_view_seconds', '45');
|
||||
$ticket = makeTicket(['number' => '7001']);
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('remaining: 45, total: 45')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
|
||||
});
|
||||
|
||||
test('client ticket view renders the configured interval and a working click-to-refresh handler', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('refresh_ticket_view_seconds', '50');
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'client-refresh@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '7002', 'customer_id' => $client->id]);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('remaining: 50, total: 50')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
|
||||
});
|
||||
|
||||
test('operator queue renders the configured interval and a working click-to-refresh handler', function () {
|
||||
Settings::set('refresh_queue_seconds', '75');
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(Queue::class)
|
||||
->assertSeeHtml('remaining: 75, total: 75')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshQueue()');
|
||||
});
|
||||
|
||||
test('notification bell polls at the configured interval', function () {
|
||||
Settings::set('refresh_notifications_seconds', '15');
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(NotificationBell::class)
|
||||
->assertSeeHtml('wire:poll.15s="$refresh"');
|
||||
});
|
||||
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal file
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save custom refresh and schedule intervals from the Konfiguracja tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.refreshTicketViewSeconds', '45')
|
||||
->set('systemConfig.refreshQueueSeconds', '90')
|
||||
->set('systemConfig.refreshNotificationsSeconds', '20')
|
||||
->set('systemConfig.scheduleSlaCheckMinutes', '10')
|
||||
->set('systemConfig.scheduleAutomationRulesMinutes', '10')
|
||||
->set('systemConfig.scheduleImapFetchMinutes', '2')
|
||||
->set('systemConfig.scheduleAiAutomationMinutes', '2')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('refresh_ticket_view_seconds'))->toBe('45');
|
||||
expect(Settings::get('refresh_queue_seconds'))->toBe('90');
|
||||
expect(Settings::get('refresh_notifications_seconds'))->toBe('20');
|
||||
expect(Settings::get('schedule_sla_check_minutes'))->toBe('10');
|
||||
expect(Settings::get('schedule_automation_rules_minutes'))->toBe('10');
|
||||
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('2');
|
||||
expect(Settings::get('schedule_ai_automation_minutes'))->toBe('2');
|
||||
});
|
||||
|
||||
test('saving a zero or negative interval clamps it to 1', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.refreshQueueSeconds', '0')
|
||||
->set('systemConfig.scheduleImapFetchMinutes', '-3')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('refresh_queue_seconds'))->toBe('1');
|
||||
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('1');
|
||||
});
|
||||
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal file
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('the scheduled command runs both triage and summary in one pass', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
|
||||
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 1]);
|
||||
|
||||
Ticket::query()->create([
|
||||
'number' => '900100',
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Test',
|
||||
'body' => 'Treść zgłoszenia.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
]);
|
||||
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [
|
||||
['message' => ['content' => '{"category": null, "subcategory": null, "subject": null, "priority": "high"}']],
|
||||
]])]);
|
||||
|
||||
$this->artisan('ai:run-ticket-automation')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('AI triage: scanned 1, changed 1, failed 0.')
|
||||
->expectsOutputToContain('AI summaries: scanned 1,');
|
||||
});
|
||||
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal file
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
test('dueEveryMinutes is true on a minute that is a multiple of the configured interval', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:14:00'));
|
||||
Settings::set('schedule_imap_fetch_minutes', '7');
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes is false on a minute that is not a multiple of the configured interval', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
|
||||
Settings::set('schedule_imap_fetch_minutes', '7');
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeFalse();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes falls back to the given default when unset', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes clamps a zero or negative stored value to 1, so it is always due', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:13:00'));
|
||||
|
||||
Settings::set('schedule_sla_check_minutes', '0');
|
||||
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
|
||||
|
||||
Settings::set('schedule_sla_check_minutes', '-4');
|
||||
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketAiSummaryService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableAiSummary(): void
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
function summaryTicket(array $overrides = []): Ticket
|
||||
{
|
||||
return Ticket::query()->create(array_merge([
|
||||
'number' => (string) random_int(100000, 999999),
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Problem z drukarką',
|
||||
'body' => 'Drukarka nie działa od rana.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function fakeAiSummaryChat(string $content): void
|
||||
{
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
||||
}
|
||||
|
||||
test('generates and stores a summary + suggested action for a fresh ticket', function () {
|
||||
enableAiSummary();
|
||||
fakeAiSummaryChat('{"summary": "Klient zgłasza awarię drukarki.", "suggested_action": "Poproś o model drukarki."}');
|
||||
|
||||
$ticket = summaryTicket();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->ai_summary)->toBe('Klient zgłasza awarię drukarki.');
|
||||
expect($ticket->ai_suggested_action)->toBe('Poproś o model drukarki.');
|
||||
expect($ticket->ai_summary_generated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a ticket with a newer message than its last summary is picked up again', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Pierwsza wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => now()->subDay()]);
|
||||
|
||||
// A message created "now" postdates the day-old summary.
|
||||
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Nowa odpowiedź operatora.']);
|
||||
|
||||
fakeAiSummaryChat('{"summary": "Zaktualizowane podsumowanie.", "suggested_action": null}');
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
||||
expect($ticket->refresh()->ai_summary)->toBe('Zaktualizowane podsumowanie.');
|
||||
});
|
||||
|
||||
test('a ticket whose summary is already newer than its latest message is not reprocessed', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('an unrelated update() that only touches updated_at does not trigger re-summarization', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
||||
|
||||
// Simulates e.g. a priority/status change touching tickets.updated_at
|
||||
// without any new ticket_messages row.
|
||||
$ticket->update(['priority_key' => 'high']);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a malformed AI response leaves the previous summary untouched and keeps the ticket stale', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => null]);
|
||||
fakeAiSummaryChat('to nie jest JSON');
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 0, 'failed' => 1]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->ai_summary)->toBe('stare podsumowanie');
|
||||
expect($ticket->ai_summary_generated_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('ai_summary_enabled=0 makes no AI calls', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_summary_enabled', '0');
|
||||
summaryTicket();
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal file
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketAiTriageService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableAiForTriage(): void
|
||||
{
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
}
|
||||
|
||||
/** @return array{cat1: Category, sub1: Subcategory, sub2: Subcategory, cat2: Category} */
|
||||
function seedCategoriesForTriage(): array
|
||||
{
|
||||
$cat1 = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub1 = $cat1->subcategories()->create(['name' => 'Drukarki i skanery']);
|
||||
$sub2 = $cat1->subcategories()->create(['name' => 'VPN']);
|
||||
$cat2 = Category::query()->create(['name' => 'Zamówienia']);
|
||||
$cat2->subcategories()->create(['name' => 'Nowe zamówienie']);
|
||||
|
||||
return compact('cat1', 'sub1', 'sub2', 'cat2');
|
||||
}
|
||||
|
||||
function seedPrioritiesForTriage(): void
|
||||
{
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 4]);
|
||||
Priority::query()->create(['key' => 'medium', 'label' => 'Średni', 'color' => '#000', 'sort_order' => 3]);
|
||||
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 2]);
|
||||
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
|
||||
}
|
||||
|
||||
function triageTicket(array $overrides = []): Ticket
|
||||
{
|
||||
return Ticket::query()->create(array_merge([
|
||||
'number' => (string) random_int(100000, 999999),
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Problem z drukarką',
|
||||
'body' => 'Drukarka HP w biurze nie drukuje od rana, pokazuje błąd papieru mimo że jest papier.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function fakeAiChat(string $content): void
|
||||
{
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
||||
}
|
||||
|
||||
test('category_when_missing assigns category+subcategory to a fully unclassified ticket', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
fakeAiChat('{"category": "IT-Pomoc", "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 1, 'failed' => 0]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subcategory_when_category_only restricts the prompt to the ticket\'s existing category and picks within it', function () {
|
||||
enableAiForTriage();
|
||||
['cat1' => $cat1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_subcategory_when_category_only', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => $cat1->id, 'subcategory_id' => null]);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub2->id);
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$content = $request['messages'][0]['content'] ?? '';
|
||||
|
||||
return str_contains($content, 'Drukarki i skanery')
|
||||
&& str_contains($content, 'VPN')
|
||||
&& ! str_contains($content, 'Nowe zamówienie');
|
||||
});
|
||||
});
|
||||
|
||||
test('recheck_categorized moves an already-categorized ticket to a better-matching subcategory', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($ticket->refresh()->subcategory_id)->toBe($sub2->id);
|
||||
});
|
||||
|
||||
test('recheck_categorized confirming the existing subcategory leaves no history and no changed count', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 0]);
|
||||
expect($ticket->refresh()->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->histories()->count())->toBe(0);
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('fix_subject rewrites an unclear subject', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_fix_subject', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": "Awaria drukarki HP w biurze", "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['subject' => 'pomocy!!!']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subject)->toBe('Awaria drukarki HP w biurze');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Temat zmieniony na: „Awaria drukarki HP w biurze”',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('set_priority assigns a priority based on content', function () {
|
||||
enableAiForTriage();
|
||||
seedPrioritiesForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
||||
|
||||
$ticket = triageTicket(['priority_key' => 'medium']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->priority_key)->toBe('high');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Priorytet zmieniony na: Wysoki',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a multi-field change writes one mechanical line per changed field plus a single attribution line', function () {
|
||||
enableAiForTriage();
|
||||
seedPrioritiesForTriage();
|
||||
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
// ticket currently sits under sub2 (VPN); AI moves it to sub1 (Drukarki i
|
||||
// skanery) AND bumps priority — both fields change in the same pass.
|
||||
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": "critical"}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub2->id, 'priority_key' => 'medium']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->priority_key)->toBe('critical');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
||||
'Priorytet zmieniony na: Krytyczny',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('idempotency: a second run does not rescan an already-triaged ticket', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
seedPrioritiesForTriage();
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
||||
|
||||
triageTicket();
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
$second = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($second)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
||||
});
|
||||
|
||||
test('a malformed AI response changes nothing but still stamps ai_triaged_at and counts as failed', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
seedPrioritiesForTriage();
|
||||
fakeAiChat('to nie jest JSON');
|
||||
|
||||
$ticket = triageTicket(['priority_key' => 'medium']);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 1]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->priority_key)->toBe('medium');
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a hallucinated category name is silently dropped rather than applied', function () {
|
||||
enableAiForTriage();
|
||||
seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
fakeAiChat('{"category": "Kategoria Zmyślona Przez Model", "subcategory": null, "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals['changed'])->toBe(0);
|
||||
$ticket->refresh();
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
expect($ticket->subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('with every triage toggle off, run makes no AI calls at all', function () {
|
||||
enableAiForTriage();
|
||||
triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
Reference in New Issue
Block a user