Files
servicedesk/src/tests/Feature/OperatorTeamScopingTest.php
Kacper 313e01ad24 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>
2026-07-24 13:38:39 +02:00

154 lines
6.7 KiB
PHP

<?php
use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Livewire\Livewire;
test('a non-admin operator only sees their own team in the queue sidebar, not every team', function () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-1@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$teamKeys = Livewire::actingAs($operator)->test(Queue::class)
->instance()->teams->pluck('name')->all();
expect($teamKeys)->toBe(['Infrastruktura']);
});
test('an admin (even if also an operator) sees every team in the queue sidebar', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op@example.com', 'roles' => ['operator', 'admin']]);
Team::query()->create(['name' => 'Infrastruktura']);
Team::query()->create(['name' => 'Aplikacje']);
$teamNames = Livewire::actingAs($admin)->test(Queue::class)
->instance()->teams->pluck('name')->sort()->values()->all();
expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']);
});
test('a non-admin operator only sees tickets from their own team, unrouted tickets, or ones assigned to them', function () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-2@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$mine = makeTicket(['number' => '1001', 'team_id' => $myTeam->id]);
$others = makeTicket(['number' => '1002', 'team_id' => $otherTeam->id]);
$unrouted = makeTicket(['number' => '1003', 'team_id' => null]);
$assignedToMeElsewhere = makeTicket(['number' => '1004', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]);
Livewire::actingAs($operator)->test(Queue::class)
->assertSee($mine->number)
->assertSee($unrouted->number)
->assertSee($assignedToMeElsewhere->number)
->assertDontSee($others->number);
});
test('an admin operator still sees tickets from every team', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op-2@example.com', 'roles' => ['operator', 'admin']]);
$teamA = Team::query()->create(['name' => 'Infrastruktura']);
$teamB = Team::query()->create(['name' => 'Aplikacje']);
$ticketA = makeTicket(['number' => '2001', 'team_id' => $teamA->id]);
$ticketB = makeTicket(['number' => '2002', 'team_id' => $teamB->id]);
Livewire::actingAs($admin)->test(Queue::class)
->assertSee($ticketA->number)
->assertSee($ticketB->number);
});
test('a non-admin operator gets a 403 opening a ticket outside their scope directly', function () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-3@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$outOfScope = makeTicket(['number' => '3001', 'team_id' => $otherTeam->id]);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $outOfScope])
->assertForbidden();
});
test('a non-admin operator can still open a ticket outside their team if it is personally assigned to them', function () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-4@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$assignedElsewhere = makeTicket(['number' => '4001', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $assignedElsewhere])
->assertOk();
});
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']);
Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]);
$teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->instance()->teams->pluck('name')->sort()->values()->all();
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 () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-6@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$mineA = makeTicket(['number' => '6001', 'team_id' => $myTeam->id]);
$mineB = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]);
$outOfScope = makeTicket(['number' => '6003', 'team_id' => $otherTeam->id]);
Livewire::actingAs($operator)->test(Queue::class)
->call('toggleSelect', $mineA->id)
->call('toggleSelect', $outOfScope->id)
->call('mergeSelected'); // only 1 ticket is actually in scope, so this must be a no-op
expect(Ticket::query()->find($outOfScope->id)->status_key)->not->toBe('closed');
Livewire::actingAs($operator)->test(Queue::class)
->call('toggleSelect', $mineA->id)
->call('toggleSelect', $mineB->id)
->call('toggleSelect', $outOfScope->id)
->call('requestDeleteSelected')
->call('confirmDeleteSelected');
expect(Ticket::query()->find($mineA->id))->toBeNull()
->and(Ticket::query()->find($mineB->id))->toBeNull()
->and(Ticket::query()->find($outOfScope->id))->not->toBeNull();
});