- 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:
2026-07-24 13:38:39 +02:00
parent 0d116dfd98
commit 313e01ad24
46 changed files with 3224 additions and 150 deletions

View 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');
});

View 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);
});

View 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');
});

View 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');
});

View 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'));
});

View 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'));
});

View File

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

View File

@@ -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'));
});

View 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"');
});

View 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');
});

View 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,');
});

View 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();
});

View 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();
});

View 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();
});