v1.1.2
- 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:
172
src/tests/Feature/AutomationRulesTest.php
Normal file
172
src/tests/Feature/AutomationRulesTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user