- 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:
2026-07-22 23:43:01 +02:00
parent 0b06687ea1
commit ab90abcaa3
47 changed files with 2480 additions and 139 deletions

View File

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

View File

@@ -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',

View File

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

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

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

View File

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

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

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

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

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

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