v1.1.3
- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on a ticket lifecycle event (created/updated/status/priority/assignee/team/ category changed, new reply), with AND-conditions and ordered actions (set status/priority/team/assignee, send e-mail). Ships its own dedicated, freely add/edit/delete-able e-mail templates, kept separate from the fixed system templates. - Ticket watching: operators can star/"Obserwuj" any ticket to follow it regardless of assignment/team. - Real-time notification bell (private per-user broadcast channel, 30s fallback poll) with an opt-in in-tab browser push notification. - Per-user notification preferences (/settings/notifications): scope (mine/unassigned/watched/all) and e-mail toggle per event category. - Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of Konfiguracja. - Operator queue: Podkategoria/Zespół/Utworzono columns (off by default). - Obserwuj button moved next to the auto-refresh countdown; trigger condition builder shows subcategory/zgłaszający as name dropdowns instead of raw IDs; /settings/notifications got a back link, full-width push card, and a bordered table container; admin panel tab and operator queue view now persist across a plain page refresh. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Coś innego')
|
||||
->assertSet('emailFooterVersion', 0);
|
||||
|
||||
@@ -50,11 +50,11 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer'));
|
||||
});
|
||||
|
||||
test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () {
|
||||
test('admin can save the email footer from the E-MAIL tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
|
||||
->assertOk()
|
||||
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
|
||||
@@ -66,7 +66,7 @@ test('the live example preview reflects the currently saved footer', function ()
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Stopka na żywo');
|
||||
|
||||
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -140,7 +141,7 @@ 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 () {
|
||||
test('every operator/admin whose new_ticket preference puts a routed ticket in scope gets notified once, no duplicates', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
@@ -152,6 +153,10 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
|
||||
$team->members()->attach([$memberA->id, $memberB->id]);
|
||||
|
||||
// auto_assign_by_category is on by default (seeded), so the ticket's
|
||||
// team_id actually becomes the VPN team's id — that's what now drives
|
||||
// who's "in scope" for the default scope_all preference, replacing the
|
||||
// old separate team-subcategory-routing fan-out.
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
@@ -161,19 +166,53 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
|
||||
Notification::assertSentTo($memberA, TicketNotification::class);
|
||||
Notification::assertSentTo($memberB, TicketNotification::class);
|
||||
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
|
||||
// true unconditionally for admins, and scope_all is the default — this
|
||||
// is intentional, it's what keeps the one real admin account notified
|
||||
// about every new ticket without any setup.
|
||||
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), 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);
|
||||
Notification::assertSentTimes(TicketNotification::class, 4);
|
||||
});
|
||||
|
||||
test('a new ticket with no matching team notifies no operator', function () {
|
||||
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', 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);
|
||||
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
|
||||
->teams()->attach($team->id);
|
||||
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif-2@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
'body' => 'Nie mogę się połączyć.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
|
||||
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
|
||||
// for them even though their preference defaults to scope_all=true.
|
||||
Notification::assertNotSentTo($outsider, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', 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']]);
|
||||
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-no-team@example.com',
|
||||
|
||||
@@ -12,7 +12,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->call('setTab', 'email')
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||
->set('mailConfig.smtpEnabled', true)
|
||||
|
||||
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('an assignee with scope_mine enabled for escalations is not double-notified on top of the direct sla_breached send', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
$assignee = operatorUser('assignee-sla@example.com');
|
||||
$ticket->update(['assignee_id' => $assignee->id]);
|
||||
|
||||
app(TicketService::class)->notify($ticket->fresh(), 'sla_breached');
|
||||
|
||||
// NotificationPreference::DEFAULTS['escalation'] has scope_mine=true, so
|
||||
// without the notify()->notifyStaffForCategory() dedup, the assignee
|
||||
// would receive this twice: once as the fixed NotificationSetting
|
||||
// recipient, once again from the scope_mine fan-out.
|
||||
Notification::assertSentToTimes($assignee, TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column off for an event still gets the bell but not a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-only@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => false])
|
||||
));
|
||||
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column on for an event gets both the bell and a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-and-mail@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['mail', 'database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('the operator performing the action is never notified about their own change', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$actor = operatorUser('actor@example.com');
|
||||
$this->actingAs($actor);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($actor, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('disabling a trigger instance-wide silences the staff fan-out too, regardless of any individual preference', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('kill-switch@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => false]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($operator, TicketNotification::class);
|
||||
});
|
||||
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a client cannot open the notification preferences page', function () {
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'client-np@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($client)->test(NotificationPreferences::class)->assertStatus(403);
|
||||
});
|
||||
|
||||
test('an operator with no saved preferences sees the built-in defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertViewHas('rows', [
|
||||
'new_ticket' => NotificationPreference::DEFAULTS['new_ticket'],
|
||||
'ticket_update' => NotificationPreference::DEFAULTS['ticket_update'],
|
||||
'escalation' => NotificationPreference::DEFAULTS['escalation'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('toggling a checkbox persists just that one field and leaves the rest at their defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'ticket_update', 'scope_all')
|
||||
->assertOk();
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'ticket_update')->firstOrFail();
|
||||
|
||||
expect($row->scope_all)->toBeTrue()
|
||||
->and($row->scope_mine)->toBe(NotificationPreference::DEFAULTS['ticket_update']['scope_mine'])
|
||||
->and($row->email)->toBe(NotificationPreference::DEFAULTS['ticket_update']['email']);
|
||||
});
|
||||
|
||||
test('toggling twice flips the field back off', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'escalation', 'email')
|
||||
->call('toggle', 'escalation', 'email');
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'escalation')->firstOrFail();
|
||||
|
||||
expect($row->email)->toBe(NotificationPreference::DEFAULTS['escalation']['email']);
|
||||
});
|
||||
|
||||
test('an unknown category or field is rejected', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'not_a_category', 'scope_all')
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('NotificationPreference::rowFor falls back to defaults when nothing is saved, and to the saved row once toggled', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket'))->toBe(NotificationPreference::DEFAULTS['new_ticket']);
|
||||
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket')['scope_all'])->toBeFalse();
|
||||
});
|
||||
@@ -58,8 +58,8 @@ test('columns can be hidden and shown again, but at least one must stay visible'
|
||||
$component->call('toggleColumn', 'sla')
|
||||
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
|
||||
|
||||
// Hide every column except one, then try to hide the last one too.
|
||||
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
|
||||
// Hide every visible-by-default column except one, then try to hide the last one too.
|
||||
foreach ((new Queue)->visibleColumns as $key) {
|
||||
if ($key !== 'number') {
|
||||
$component->call('toggleColumn', $key);
|
||||
}
|
||||
|
||||
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('sending a bell notification to a real user dispatches NotificationCreated on their private channel', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('realtime-bell@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator'));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, function (NotificationCreated $event) use ($operator, $ticket) {
|
||||
return $event->userId === $operator->id
|
||||
&& str_contains($event->message, $ticket->number)
|
||||
&& $event->url === route('operator.ticket', $ticket);
|
||||
});
|
||||
});
|
||||
|
||||
test('a bell-only notification (no mail channel) still dispatches NotificationCreated', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('bell-only-realtime@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-bell-only', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator', ['database']));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, fn (NotificationCreated $event) => $event->userId === $operator->id);
|
||||
});
|
||||
|
||||
test('a guest customer notified by mail only never broadcasts a bell event', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-guest', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
Notification::route('mail', $ticket->email)
|
||||
->notify(new TicketNotification($ticket, $template->id));
|
||||
|
||||
Event::assertNotDispatched(NotificationCreated::class);
|
||||
});
|
||||
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the admin panel remembers the active tab across a fresh page load via the URL', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSet('tab', 'integrations')
|
||||
->assertSee('LDAP / Active Directory')
|
||||
->assertSee('Baza wiedzy BookStack')
|
||||
->assertDontSee('Sesja i strefa czasowa');
|
||||
});
|
||||
|
||||
test('LDAP and BookStack config moved out of the Konfiguracja tab into their own Integracje tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$config = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'config']);
|
||||
$config->assertSee('Sesja i strefa czasowa')
|
||||
->assertDontSee('LDAP / Active Directory')
|
||||
->assertDontSee('Baza wiedzy BookStack');
|
||||
});
|
||||
|
||||
test('the operator queue remembers the active queue tab across a fresh page load via the URL', function () {
|
||||
$operator = operatorUser('queue-persist@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class, ['queue' => 'mine'])
|
||||
->assertSet('queue', 'mine');
|
||||
});
|
||||
|
||||
test('the notifications settings page has a back link to the operator queue', function () {
|
||||
$operator = operatorUser('settings-nav@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertSeeHtml(route('operator.queue'));
|
||||
});
|
||||
45
src/tests/Feature/TicketWatchingTest.php
Normal file
45
src/tests/Feature/TicketWatchingTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('an operator can watch and unwatch a ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->isWatchedBy($operator))->toBeFalse();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue()
|
||||
->and($operator->watchedTickets()->pluck('tickets.id'))->toContain($ticket->id);
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the ticket-show watch button toggles watch state for the viewing operator', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSet('isWatching', false)
|
||||
->call('toggleWatch')
|
||||
->assertSet('isWatching', true);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue();
|
||||
});
|
||||
|
||||
test('watching a ticket is per-operator, not shared', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$watcher = operatorUser('watcher@example.com');
|
||||
$other = operatorUser('other@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $watcher);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($watcher))->toBeTrue()
|
||||
->and($ticket->fresh()->isWatchedBy($other))->toBeFalse();
|
||||
});
|
||||
172
src/tests/Feature/TriggerEngineTest.php
Normal file
172
src/tests/Feature/TriggerEngineTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('a trigger with no conditions fires on every matching event', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Always high on update', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['priority_key' => 'low']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a trigger only fires when every condition matches (AND)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$team = Team::query()->create(['name' => 'VIP']);
|
||||
Trigger::query()->create([
|
||||
'name' => 'High priority to VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']],
|
||||
'actions' => [['type' => 'set_team', 'value' => $team->id]],
|
||||
]);
|
||||
|
||||
$lowTicket = makeTicket(['number' => '2001', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($lowTicket, 'high');
|
||||
expect($lowTicket->fresh()->team_id)->toBe($team->id);
|
||||
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$otherTicket = makeTicket(['number' => '2002', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($otherTicket, 'low');
|
||||
expect($otherTicket->fresh()->team_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a disabled trigger never fires', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Disabled', 'enabled' => false, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('new');
|
||||
});
|
||||
|
||||
test('is_empty and is_not_empty operators work without a value', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'No team yet -> VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'team_id', 'operator' => 'is_empty', 'value' => null]],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('the contains operator matches substrings case-insensitively', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'VPN keyword -> closed', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'subject', 'operator' => 'contains', 'value' => 'VPN']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket(['subject' => 'Problem z vpn na laptopie']);
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('closed');
|
||||
});
|
||||
|
||||
test('an action that would only reassert the current value is a no-op and does not re-trigger anything', function () {
|
||||
seedStatusesAndPriorities();
|
||||
// If this looped, it would recurse until TriggerEngine's depth guard
|
||||
// kicked in; asserting the final state (rather than call counts) proves
|
||||
// the no-op short-circuit stopped it after a single, harmless pass.
|
||||
Trigger::query()->create([
|
||||
'name' => 'Keep status open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('two triggers that keep flipping the same field between each other are bounded by the depth guard, not an infinite loop', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'To open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'open']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'To new', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'new']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'new']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
// Would hang/exceed PHP's execution time without the depth guard —
|
||||
// simply completing is the assertion.
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBeIn(['new', 'open']);
|
||||
});
|
||||
|
||||
test('the send_notification action sends the chosen template to the chosen recipient, ignoring NotificationSetting entirely', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$template = TriggerEmailTemplate::query()->create([
|
||||
'name' => 'x', 'subject' => 'Priorytet zmieniony na {priorytet}', 'body' => 'B',
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'Notify on high priority', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'send_notification', 'recipient' => 'client', 'email_template_id' => $template->id]],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
|
||||
// Trigger notifications draw from trigger_email_templates, not the
|
||||
// fixed email_templates table used by NotificationSetting.
|
||||
$mail = (new TicketNotification($ticket->fresh(), $template->id, templateSource: 'trigger_email_template'))
|
||||
->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->subject)->toBe('Priorytet zmieniony na Wysoki');
|
||||
});
|
||||
|
||||
test('a trigger with an unrecognized action type is silently ignored, not fatal', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Bogus action', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'not_a_real_action']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a client reply fires the comment_added event, even though clientReply() never fired a notification trigger before', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Reopen on client reply', 'enabled' => true, 'event' => 'comment_added',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'equals', 'value' => 'closed']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'closed']);
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'reopener@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Nadal mam problem.');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
146
src/tests/Feature/TriggersAdminTest.php
Normal file
146
src/tests/Feature/TriggersAdminTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\Triggers;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can create a trigger with a condition and an action', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', 'Priorytet wysoki -> status otwarty')
|
||||
->set('form.event', 'priority_changed')
|
||||
->call('addCondition')
|
||||
->set('form.conditions.0.field', 'priority_key')
|
||||
->set('form.conditions.0.operator', 'equals')
|
||||
->set('form.conditions.0.value', 'high')
|
||||
->call('addAction')
|
||||
->set('form.actions.0.type', 'set_status')
|
||||
->set('form.actions.0.value', 'open')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
$trigger = Trigger::query()->where('name', 'Priorytet wysoki -> status otwarty')->firstOrFail();
|
||||
expect($trigger->event)->toBe('priority_changed')
|
||||
->and($trigger->conditions)->toBe([['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']])
|
||||
->and($trigger->actions[0]['type'])->toBe('set_status')
|
||||
->and($trigger->actions[0]['value'])->toBe('open');
|
||||
});
|
||||
|
||||
test('a trigger requires a name and at least one action', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', '')
|
||||
->call('submit')
|
||||
->assertHasErrors(['form.name', 'form.actions']);
|
||||
});
|
||||
|
||||
test('admin can edit an existing trigger', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Original', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTrigger', $trigger->id)
|
||||
->set('form.name', 'Renamed')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
expect($trigger->fresh()->name)->toBe('Renamed');
|
||||
});
|
||||
|
||||
test('admin can toggle a trigger on/off and delete it', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Toggle me', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('toggleEnabled', $trigger->id);
|
||||
expect($trigger->fresh()->enabled)->toBeFalse();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTrigger', $trigger->id);
|
||||
expect(Trigger::query()->find($trigger->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can reorder triggers with move up/down', function () {
|
||||
$admin = adminUser();
|
||||
$first = Trigger::query()->create(['name' => 'A', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 1]);
|
||||
$second = Trigger::query()->create(['name' => 'B', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 2]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('moveDown', $first->id);
|
||||
|
||||
expect($first->fresh()->sort_order)->toBe(2)
|
||||
->and($second->fresh()->sort_order)->toBe(1);
|
||||
});
|
||||
|
||||
test('adding and removing condition/action rows in the form works', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->call('addCondition')
|
||||
->call('addCondition')
|
||||
->assertCount('form.conditions', 2)
|
||||
->call('removeCondition', 0)
|
||||
->assertCount('form.conditions', 1)
|
||||
->call('addAction')
|
||||
->assertCount('form.actions', 1);
|
||||
|
||||
$component->call('removeAction', 0)->assertCount('form.actions', 0);
|
||||
});
|
||||
|
||||
test('the admin panel triggers tab renders the Triggers component', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'triggers')
|
||||
->assertSeeLivewire(Triggers::class);
|
||||
});
|
||||
|
||||
test('admin can create, edit and delete a trigger email template, independent of the fixed email templates', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->set('templateForm.name', 'Przypomnienie')
|
||||
->set('templateForm.subject', 'Temat')
|
||||
->set('templateForm.body', 'Treść')
|
||||
->call('submitTemplate')
|
||||
->assertSet('templateFormOpen', false);
|
||||
|
||||
$template = TriggerEmailTemplate::query()->where('name', 'Przypomnienie')->firstOrFail();
|
||||
expect($template->subject)->toBe('Temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTemplate', $template->id)
|
||||
->set('templateForm.subject', 'Nowy temat')
|
||||
->call('submitTemplate');
|
||||
|
||||
expect($template->fresh()->subject)->toBe('Nowy temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTemplate', $template->id);
|
||||
|
||||
expect(TriggerEmailTemplate::query()->find($template->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a trigger email template requires a name, subject and body', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->call('submitTemplate')
|
||||
->assertHasErrors(['templateForm.name', 'templateForm.subject', 'templateForm.body']);
|
||||
});
|
||||
Reference in New Issue
Block a user