- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0b06687ea1
64 changed files with 3613 additions and 168 deletions

View File

@@ -0,0 +1,172 @@
<?php
use App\Models\AutomationRule;
use App\Models\Priority;
use App\Models\Team;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Artisan;
test('a rule fires once a ticket has been silent past its threshold and applies its action', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
// TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
// history line; RunAutomationRules adds a second, clearly-attributed one.
expect($ticket->fresh()->histories()->count())->toBe(2);
});
test('a rule does not fire again on the same ticket once already latched', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
$ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
$ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
});
test('a rule scoped to a priority ignores tickets with a different priority', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Tylko dla wysokiego priorytetu',
'enabled' => true,
'condition_minutes' => 60,
'scope_priority_key' => 'high',
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($ticket->fresh()->histories()->count())->toBe(0);
});
test('a disabled rule never fires', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Wyłączona reguła',
'enabled' => false,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a closed ticket is never matched even if it has been silent long enough', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('two independent rules can both fire on the same ticket in one run', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$team = Team::query()->create(['name' => 'Wsparcie L2']);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
AutomationRule::query()->create([
'label' => 'Przekaż do L2',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_team',
'action_value' => (string) $team->id,
]);
Artisan::call('automation:run-rules');
$ticket->refresh();
expect($ticket->priority_key)->toBe('low');
expect($ticket->team_id)->toBe($team->id);
expect($ticket->histories()->count())->toBe(4);
});
test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($rule->logs()->count())->toBe(1);
app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
expect($rule->logs()->count())->toBe(0);
});

View File

@@ -3,13 +3,15 @@
use App\Livewire\Admin\Panel;
use App\Models\EmailTemplate;
use App\Models\NotificationSetting;
use App\Models\User;
use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
Livewire::actingAs($admin)->test(Panel::class)
@@ -26,10 +28,11 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
});
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
// A bare migrated (unseeded) database has no email_templates rows yet, so
// ticket_created's binding is still null at this point — give it one.
// Repoint ticket_created's NotificationSetting at a template we control,
// instead of the real seeded one, so this test's assertions are its own.
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
$ticketCreated->update(['email_template_id' => $template->id]);
@@ -50,7 +53,7 @@ test('admin can edit the subject and body of a trigger\'s fixed template', funct
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$template = EmailTemplate::query()->create([
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',

View File

@@ -9,19 +9,21 @@ use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
test('the 10 notification triggers exist, with the customer-facing lifecycle ones (and the new-ticket-for-team one) enabled by default and the rest opt-in', function () {
$this->seed();
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
expect($settings->keys()->sort()->values()->all())->toBe([
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
'ticket_created_team',
]);
foreach (['ticket_created', 'status_changed'] as $key) {
foreach (['ticket_created', 'status_changed', 'ticket_closed', 'operator_replied', 'ticket_created_team'] as $key) {
expect($settings[$key]->enabled)->toBeTrue();
}
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'sla_breached'] as $key) {
expect($settings[$key]->enabled)->toBeFalse()
->and($settings[$key]->email_template_id)->not->toBeNull();
}
@@ -29,7 +31,7 @@ test('the 6 extended triggers exist and are disabled by default, alongside the 2
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
@@ -42,7 +44,7 @@ test('a disabled-by-default trigger sends nothing until an admin turns it on', f
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -57,7 +59,7 @@ test('changing the assignee fires assignee_changed with the {operator} placehold
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -72,7 +74,7 @@ test('changing the team fires team_changed with the {zespol} placeholder once en
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
$category = Category::query()->create(['name' => 'IT-Pomoc']);
@@ -92,12 +94,12 @@ test('changing the subcategory fires category_changed, but re-saving details wit
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
// status_changed ships enabled by default, but in a bare migrated (unseeded)
// database it has no template assigned yet — give it one so it would have
// something to send if it (wrongly) fired, isolating this test from seeding order.
// status_changed ships enabled by default — repoint it at a template we
// control so this test's "did it wrongly fire" assertion isn't tied to
// whatever content the real seeded template happens to have.
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
@@ -112,7 +114,7 @@ test('closing a ticket fires only ticket_closed, not status_changed, so it does
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
@@ -128,7 +130,7 @@ test('a non-closing status change still fires status_changed as usual', function
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
$ticket = makeTicket();
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
@@ -137,3 +139,48 @@ test('an operator reply fires operator_replied once enabled, independent of any
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'VPN']);
$team = Team::query()->create(['name' => 'Zespół VPN']);
$team->subcategories()->attach($sub->id);
$memberA = User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']]);
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]);
app(TicketService::class)->create([
'email' => 'client-team-notif@example.com',
'subject' => 'Problem z VPN',
'body' => 'Nie mogę się połączyć.',
'subcategory_id' => $sub->id,
], null);
Notification::assertSentTo($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, TicketNotification::class);
// Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account.
Notification::assertSentTimes(TicketNotification::class, 3);
});
test('a new ticket with no matching team notifies no operator', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'Bez zespołu']);
$sub = $category->subcategories()->create(['name' => 'Inne']);
$operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
app(TicketService::class)->create([
'email' => 'client-no-team@example.com',
'subject' => 'Coś innego',
'body' => 'Treść.',
'subcategory_id' => $sub->id,
], null);
Notification::assertNotSentTo($operator, TicketNotification::class);
});

View File

@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
use Livewire\Livewire;
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
$this->seed();
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
// "resolved" status (folded into "closed" — see the status restructure
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
@@ -72,7 +74,7 @@ test('admin can edit and delete a reply quick action', function () {
});
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-view@example.com');
$ticket = makeTicket(['number' => '1001']);
@@ -83,7 +85,7 @@ test('the operator ticket view lists the configured reply quick actions in the s
});
test('sending via a status-changing quick action updates the ticket status', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-send@example.com');
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();

View File

@@ -21,7 +21,7 @@ test('the SLA-breach check does nothing while its trigger is disabled (the defau
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
$operator = User::query()->create(['name' => 'Ola Operator', 'email' => 'sla-op-2@example.com', 'roles' => ['operator']]);
@@ -29,15 +29,15 @@ test('an overdue ticket with an assigned operator gets notified once the trigger
Artisan::call('tickets:check-sla-breaches');
Notification::assertSentOnDemand(
TicketNotification::class,
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com'
);
// $operator is a real persisted User (not a guest), so TicketService::notify()
// notifies it directly rather than routing anonymously — assertSentTo, not
// assertSentOnDemand (which only matches AnonymousNotifiable routing).
Notification::assertSentTo($operator, TicketNotification::class);
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
Artisan::call('tickets:check-sla-breaches');
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
Notification::assertSentTimes(TicketNotification::class, 1);
});
test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () {

View File

@@ -8,11 +8,25 @@ use Livewire\Livewire;
test('every page renders without error against fully seeded data', function () {
$this->seed();
$client = User::query()->withRole('client')->firstOrFail();
$operator = User::query()->withRole('operator')->firstOrFail();
// DatabaseSeeder deliberately only seeds one admin fallback account and
// no ticket data (see README) — this test needs a client and an operator
// plus a ticket to exercise every page, so it creates its own on top of
// the seeded reference data (categories/teams/statuses/priorities/etc).
$client = User::query()->create(['name' => 'Smoke Client', 'email' => 'smoke-client@example.com', 'roles' => ['client']]);
$operator = User::query()->create(['name' => 'Smoke Operator', 'email' => 'smoke-operator@example.com', 'roles' => ['operator']]);
$admin = User::query()->withRole('admin')->firstOrFail();
$clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail();
$anyTicket = Ticket::query()->firstOrFail();
$clientTicket = Ticket::query()->create([
'number' => '9001',
'customer_id' => $client->id,
'email' => $client->email,
'name' => $client->name,
'subject' => 'Smoke test subject',
'body' => 'Smoke test body',
'status_key' => 'new',
'priority_key' => 'high',
'custom_fields' => [],
]);
$anyTicket = $clientTicket;
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');

View File

@@ -0,0 +1,48 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
test('csatByTeam averages ratings per team and drops teams with no ratings', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin@example.com', 'roles' => ['admin']]);
$teamA = Team::query()->create(['name' => 'Zespół A']);
$teamB = Team::query()->create(['name' => 'Zespół B']);
$teamC = Team::query()->create(['name' => 'Zespół C (bez ocen)']);
makeTicket(['number' => '2001', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 5]);
makeTicket(['number' => '2002', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 3]);
makeTicket(['number' => '2003', 'status_key' => 'closed', 'team_id' => $teamB->id, 'csat_rating' => 4]);
makeTicket(['number' => '2004', 'status_key' => 'closed', 'team_id' => $teamC->id, 'csat_rating' => null]);
makeTicket(['number' => '2005', 'status_key' => 'closed', 'team_id' => null, 'csat_rating' => 2]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByTeam;
expect($rows->firstWhere('label', 'Zespół A'))->toBe(['label' => 'Zespół A', 'avg' => 4.0, 'count' => 2])
->and($rows->firstWhere('label', 'Zespół B'))->toBe(['label' => 'Zespół B', 'avg' => 4.0, 'count' => 1])
->and($rows->firstWhere('label', 'Bez zespołu'))->toBe(['label' => 'Bez zespołu', 'avg' => 2.0, 'count' => 1])
->and($rows->firstWhere('label', 'Zespół C (bez ocen)'))->toBeNull();
});
test('csatByAssignee averages ratings per operator and drops operators with no ratings', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-2@example.com', 'roles' => ['admin']]);
$opA = User::query()->create(['name' => 'Ola', 'email' => 'stats-op-a@example.com', 'roles' => ['operator']]);
$opB = User::query()->create(['name' => 'Jan', 'email' => 'stats-op-b@example.com', 'roles' => ['operator']]);
$opUnrated = User::query()->create(['name' => 'Bez ocen', 'email' => 'stats-op-c@example.com', 'roles' => ['operator']]);
makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 5]);
makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 1]);
makeTicket(['number' => '3003', 'status_key' => 'closed', 'assignee_id' => $opB->id, 'csat_rating' => 3]);
makeTicket(['number' => '3004', 'status_key' => 'closed', 'assignee_id' => null, 'csat_rating' => 4]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByAssignee;
expect($rows->firstWhere('label', 'Ola'))->toBe(['label' => 'Ola', 'avg' => 3.0, 'count' => 2])
->and($rows->firstWhere('label', 'Jan'))->toBe(['label' => 'Jan', 'avg' => 3.0, 'count' => 1])
->and($rows->firstWhere('label', 'Nieprzypisane'))->toBe(['label' => 'Nieprzypisane', 'avg' => 4.0, 'count' => 1])
->and($rows->firstWhere('label', 'Bez ocen'))->toBeNull();
});

View File

@@ -0,0 +1,44 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\User;
use Livewire\Livewire;
test('byCustomer ranks registered clients by ticket volume and sums guest tickets into one bucket', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust@example.com', 'roles' => ['admin']]);
$clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-client-b@example.com', 'roles' => ['client']]);
makeTicket(['number' => '5001', 'customer_id' => $clientA->id]);
makeTicket(['number' => '5002', 'customer_id' => $clientA->id]);
makeTicket(['number' => '5003', 'customer_id' => $clientB->id]);
makeTicket(['number' => '5004', 'customer_id' => null]);
makeTicket(['number' => '5005', 'customer_id' => null]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
expect($rows->firstWhere('label', 'Klient A'))->toBe(['label' => 'Klient A', 'count' => 2])
->and($rows->firstWhere('label', 'Klient B'))->toBe(['label' => 'Klient B', 'count' => 1])
->and($rows->firstWhere('label', 'Goście (bez konta)'))->toBe(['label' => 'Goście (bez konta)', 'count' => 2]);
});
test('byCustomer caps the ranking at the top 10 clients by volume', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-2@example.com', 'roles' => ['admin']]);
$ticketNumber = 6000;
foreach (range(1, 12) as $i) {
$client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-client-{$i}@example.com", 'roles' => ['client']]);
// Give each client a distinct ticket count (12 down to 1) so ranking is deterministic.
foreach (range(1, 13 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id]);
}
}
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
expect($rows)->toHaveCount(10)
->and($rows->first())->toBe(['label' => 'Klient 1', 'count' => 12]);
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('customerSubcategoryMatrix cross-tabs clients against their top subcategories', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix@example.com', 'roles' => ['admin']]);
$clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-matrix-client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-matrix-client-b@example.com', 'roles' => ['client']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$printers = $it->subcategories()->create(['name' => 'Drukarki']);
makeTicket(['number' => '9001', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '9002', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '9003', 'customer_id' => $clientA->id, 'subcategory_id' => $printers->id]);
makeTicket(['number' => '9004', 'customer_id' => $clientB->id, 'subcategory_id' => $printers->id]);
makeTicket(['number' => '9005', 'customer_id' => null, 'subcategory_id' => $vpn->id]);
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['columns'])->toBe(['IT-Pomoc / VPN', 'IT-Pomoc / Drukarki'])
->and($matrix['hasOther'])->toBeFalse();
$rowsByLabel = collect($matrix['rows'])->keyBy('label');
expect($rowsByLabel['Klient A'])->toBe(['label' => 'Klient A', 'cells' => [2, 1], 'other' => null, 'total' => 3])
->and($rowsByLabel['Klient B'])->toBe(['label' => 'Klient B', 'cells' => [0, 1], 'other' => null, 'total' => 1]);
});
test('customerSubcategoryMatrix caps rows at top 10 clients and columns at top 5 subcategories, folding the rest into "Inne"', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-2@example.com', 'roles' => ['admin']]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'stats-matrix-client-c@example.com', 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Kategoria']);
$ticketNumber = 9100;
foreach (range(1, 7) as $i) {
$sub = $category->subcategories()->create(['name' => "Sub {$i}"]);
// Distinct volumes (7 down to 1) so which 5 make the cut is deterministic.
foreach (range(1, 8 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
}
}
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['columns'])->toBe(['Kategoria / Sub 1', 'Kategoria / Sub 2', 'Kategoria / Sub 3', 'Kategoria / Sub 4', 'Kategoria / Sub 5'])
->and($matrix['hasOther'])->toBeTrue();
$row = $matrix['rows'][0];
expect($row['cells'])->toBe([7, 6, 5, 4, 3])
->and($row['other'])->toBe(2 + 1)
->and($row['total'])->toBe(7 + 6 + 5 + 4 + 3 + 2 + 1);
});
test('customerSubcategoryMatrix caps rows at the top 10 clients by volume', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-3@example.com', 'roles' => ['admin']]);
$category = Category::query()->create(['name' => 'Kategoria']);
$sub = $category->subcategories()->create(['name' => 'Sub']);
$ticketNumber = 9200;
foreach (range(1, 12) as $i) {
$client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-matrix-client-{$i}@example.com", 'roles' => ['client']]);
foreach (range(1, 13 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
}
}
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['rows'])->toHaveCount(10)
->and($matrix['rows'][0]['label'])->toBe('Klient 1')
->and($matrix['rows'][0]['total'])->toBe(12);
});

View File

@@ -0,0 +1,30 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('bySubcategory groups tickets per subcategory, labeled "Category / Subcategory"', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-sub@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$printers = $it->subcategories()->create(['name' => 'Drukarki']);
$orders = Category::query()->create(['name' => 'Zamówienia']);
$hardware = $orders->subcategories()->create(['name' => 'Sprzęt']);
makeTicket(['number' => '4001', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4002', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4003', 'subcategory_id' => $printers->id]);
makeTicket(['number' => '4004', 'subcategory_id' => $hardware->id]);
makeTicket(['number' => '4005', 'subcategory_id' => null]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 2])
->and($rows->firstWhere('label', 'IT-Pomoc / Drukarki'))->toBe(['label' => 'IT-Pomoc / Drukarki', 'count' => 1])
->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
->and($rows->count())->toBe(3);
});