v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a ticket or reply to an existing one just by sending/replying to an e-mail. Configure any number of mailboxes in the new Admin > Poczta page (SMTP + IMAP together, replacing the old "E-MAIL" tab), each routed to a specific subcategory or a whole category (new tickets.category_id column). Replies are matched to their ticket via the number/checksum already in every notification subject; autoresponders/bounces are detected and rejected; "restrict tickets to LDAP" is enforced for e-mail like the guest web form. Manual "Pobierz teraz" per-mailbox fetch button; dedicated storage/logs/imap-*.log regardless of the app's log level; mail-icon badges on e-mail-originated tickets/messages in the operator queue and ticket view. - Operator queue: "select all" checkbox in the table header for every currently visible ticket under the active filter/tab. - Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP fetch) always sent notifications through .env's default mailer instead of the configured SMTP server, because AppServiceProvider's Settings override used to skip itself for any console command, not just migrate. - Fixed: visiting a ticket that no longer exists (deleted mid-session, or a stale background refresh) showed a raw 404 instead of redirecting back to the operator queue / client dashboard. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above, including the previously-missing host crontab entry for schedule:run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('visiting a deleted ticket as an operator redirects to the operator queue instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket();
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($operator)
|
||||
->get("/operator/tickets/{$id}")
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('visiting a deleted ticket as a client redirects to the client dashboard instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id]);
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get("/client/tickets/{$id}")
|
||||
->assertRedirect(route('client.dashboard'));
|
||||
});
|
||||
|
||||
test('a guest hitting a non-existent ticket route still gets the normal (non-redirected) handling', function () {
|
||||
$this->get('/operator/tickets/999999')->assertRedirect(route('login'));
|
||||
});
|
||||
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// ===================== TicketService::create() category-only routing =====================
|
||||
|
||||
test('create() sets category_id when only a category is given (no subcategory)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'category_id' => $category->id,
|
||||
'subject' => 'Sprawa delegacji',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBe($category->id)
|
||||
->and($ticket->subcategory_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('Delegacje');
|
||||
});
|
||||
|
||||
test('create() leaves category_id null when a subcategory is given (category is derived from it)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'subcategory_id' => $subcategory->id,
|
||||
'subject' => 'Sprawa VPN',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('IT / VPN');
|
||||
});
|
||||
|
||||
test('create() defaults source to web, and accepts an explicit source', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$webTicket = app(TicketService::class)->create([
|
||||
'email' => 'a@example.com', 'subject' => 'S', 'body' => 'B',
|
||||
], null);
|
||||
|
||||
$mailTicket = app(TicketService::class)->create([
|
||||
'email' => 'b@example.com', 'subject' => 'S', 'body' => 'B', 'source' => 'email',
|
||||
], null);
|
||||
|
||||
expect($webTicket->source)->toBe('web')
|
||||
->and($mailTicket->source)->toBe('email');
|
||||
});
|
||||
|
||||
// ===================== ImapMailbox::targetLabel() =====================
|
||||
|
||||
test('targetLabel reflects subcategory, whole-category, or neither', function () {
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$bySubcategory = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
$byCategory = ImapMailbox::query()->create(mailboxFixtureData(['default_category_id' => $category->id]));
|
||||
$unrouted = ImapMailbox::query()->create(mailboxFixtureData());
|
||||
|
||||
expect($bySubcategory->targetLabel())->toBe('IT / VPN')
|
||||
->and($byCategory->targetLabel())->toBe('Cała kategoria: Delegacje')
|
||||
->and($unrouted->targetLabel())->toBe('—');
|
||||
});
|
||||
|
||||
function mailboxFixtureData(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Test',
|
||||
'enabled' => true,
|
||||
'host' => 'imap.example.com',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validate_cert' => true,
|
||||
'username' => 'test@example.com',
|
||||
'password' => 'secret',
|
||||
'folder' => 'INBOX',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// ===================== Admin: MailSettings mailbox form =====================
|
||||
|
||||
test('admin can route a mailbox to a whole category via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia delegacji')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-delegacje@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia delegacji')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can route a mailbox to a specific subcategory via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia IT')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-it@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia IT')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_subcategory_id)->toBe($subcategory->id)
|
||||
->and($mailbox->default_category_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('switching an existing mailbox from a subcategory to a whole category clears the old target', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$mailbox = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('editMailbox', $mailbox->id)
|
||||
->assertSet('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox->refresh();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== Operator UI: e-mail source badge =====================
|
||||
|
||||
test('the operator queue shows a mail icon next to an e-mail-originated ticket but not a web one', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$webTicket = makeTicket(['number' => '2001', 'source' => 'web']);
|
||||
$mailTicket = makeTicket(['number' => '2002', 'source' => 'email']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->assertSeeHtml('title="Utworzone przez e-mail"');
|
||||
});
|
||||
|
||||
test('the ticket detail header shows an e-mail badge only for e-mail-originated tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$mailTicket = makeTicket(['number' => '2003', 'source' => 'email']);
|
||||
$webTicket = makeTicket(['number' => '2004', 'source' => 'web']);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $mailTicket])
|
||||
->assertSee('E-mail');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $webTicket])
|
||||
->assertDontSee('E-mail');
|
||||
});
|
||||
|
||||
// ===================== Operator queue: category-only tickets are filterable =====================
|
||||
|
||||
test('filtering the queue by category includes a ticket routed to that whole category with no subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
$ticket = makeTicket(['number' => '2005', 'category_id' => $category->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('filterCategory', $category->id)
|
||||
->assertSee($ticket->displayNumber());
|
||||
});
|
||||
|
||||
// ===================== Operator UI: per-message e-mail source badge =====================
|
||||
|
||||
test('a reply fetched by e-mail shows a mail badge in the thread, a normal client reply does not', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '2006']);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('title="Odebrane e-mailem"');
|
||||
});
|
||||
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use App\Services\ImapMessageClassifier;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
function makeInboundEmail(array $overrides = []): InboundEmail
|
||||
{
|
||||
return new InboundEmail(
|
||||
fromEmail: $overrides['fromEmail'] ?? 'klient@example.com',
|
||||
fromName: $overrides['fromName'] ?? 'Jan Kowalski',
|
||||
subject: $overrides['subject'] ?? 'Zwykła wiadomość',
|
||||
textBody: $overrides['textBody'] ?? 'Treść wiadomości.',
|
||||
htmlBody: $overrides['htmlBody'] ?? '',
|
||||
headers: $overrides['headers'] ?? [],
|
||||
attachments: $overrides['attachments'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== rejectionReason() =====================
|
||||
|
||||
test('a normal reply is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->rejectionReason(makeInboundEmail()))->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted header other than "no" is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'auto-replied']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted: no is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'no']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('X-Autoreply header is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['x-autoreply' => '1']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('regression: an empty-string header value (present key, no content) is treated as absent, not rejected', function () {
|
||||
// Reproduces the real production bug: Webklex's Header::get() returns
|
||||
// an empty (non-null) Attribute for a header that isn't on the message
|
||||
// at all, so a naive "!== null" check on x-autoreply/x-autorespond
|
||||
// rejected every single inbound e-mail.
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => [
|
||||
'auto-submitted' => '', 'x-autoreply' => '', 'x-autorespond' => '', 'precedence' => '',
|
||||
]]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('Precedence: bulk is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['precedence' => 'bulk']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a blocklisted sender is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['fromEmail' => 'mailer-daemon@example.com']);
|
||||
|
||||
expect($classifier->rejectionReason($email, ['mailer-daemon']))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an out-of-office subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatic reply: Out of Office']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a Polish autoresponder subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatyczna odpowiedz: nieobecnosc w biurze']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== matchTicket() =====================
|
||||
|
||||
test('matches an existing ticket by its plain sequential number in the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Re: Aktualizacja zgłoszenia #1042')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('matches an existing ticket by its checksum when obfuscation is enabled', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket("Re: Aktualizacja zgłoszenia #{$ticket->checksum}")->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('returns null when no digit run in the subject matches any ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Nowa sprawa bez numeru'))->toBeNull();
|
||||
});
|
||||
|
||||
test('strips common reply/forward prefixes before matching', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
foreach (['Re:', 'RE:', 'Odp:', 'Fwd:', 'FW:', 'Aw:'] as $prefix) {
|
||||
expect($classifier->matchTicket("{$prefix} Zgłoszenie #1042")->id)->toBe($ticket->id);
|
||||
}
|
||||
});
|
||||
|
||||
test('when the subject has multiple digit runs, the one that actually resolves wins', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
// "2026" (a year, 4 digits) doesn't resolve to any ticket; "1042" does.
|
||||
expect($classifier->matchTicket('Zgłoszenie #1042 z dnia 2026-07-23')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
// ===================== isSenderAllowed() / resolveSender() =====================
|
||||
|
||||
test('isSenderAllowed allows any e-mail when restrict_tickets_to_ldap is off (the default)', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('ktokolwiek@example.com'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed rejects an unknown e-mail when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('nieznany@firma.pl'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an e-mail that exists in LDAP when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.gosc',
|
||||
'cn' => 'Znany Gość',
|
||||
'mail' => 'znany.gosc@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('znany.gosc@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an already-known local account even when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('istniejacy@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('resolveSender returns an existing local user without touching LDAP', function () {
|
||||
$user = User::query()->create(['name' => 'Istniejący', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('istniejacy@firma.pl')->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('resolveSender returns null for an unprovisionable guest', function () {
|
||||
Settings::set('ldap_auto_provision_guests', '0');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('nikt@example.com'))->toBeNull();
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'email')
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||
->set('mailConfig.smtpEnabled', true)
|
||||
@@ -31,7 +31,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
|
||||
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
|
||||
|
||||
// Saving again with a blank password field must not wipe the stored one.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->set('mailConfig.smtpPassword', '')
|
||||
->call('saveMailConfig')
|
||||
@@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc
|
||||
Mail::fake();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('testMailConnection')
|
||||
->assertSet('mailTestResult', 'error');
|
||||
|
||||
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
|
||||
// reports success — this exercises the same config-override/restore path
|
||||
// real sends use, without needing a reachable SMTP server in tests.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->call('testMailConnection')
|
||||
@@ -109,3 +109,30 @@ test('AppServiceProvider always applies the from-address override regardless of
|
||||
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
|
||||
->and(config('mail.from.name'))->toBe('Wsparcie');
|
||||
});
|
||||
|
||||
test('regression: the mail override still applies for a console command other than migrate (e.g. schedule:run/tinker)', function () {
|
||||
// Reproduces the real production bug: settingsTableUsable() used to
|
||||
// blanket-skip for *any* console command, which meant scheduled
|
||||
// commands (emails:fetch-imap, tickets:check-sla-breaches) always sent
|
||||
// mail via the .env "log" mailer instead of the configured SMTP server,
|
||||
// since AppServiceProvider::boot() runs on every process including
|
||||
// console ones. Only the migrate family should still be excluded.
|
||||
$originalArgv = $_SERVER['argv'] ?? null;
|
||||
|
||||
Settings::set('mail_smtp_enabled', '1');
|
||||
Settings::set('mail_smtp_host', 'smtp.enabled.example');
|
||||
|
||||
try {
|
||||
$_SERVER['argv'] = ['artisan', 'emails:fetch-imap'];
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
expect(config('mail.default'))->toBe('smtp');
|
||||
|
||||
Config::set('mail.default', 'log');
|
||||
|
||||
$_SERVER['argv'] = ['artisan', 'migrate'];
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
expect(config('mail.default'))->not->toBe('smtp');
|
||||
} finally {
|
||||
$_SERVER['argv'] = $originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('toggleSelectAll selects every currently visible ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-1@example.com');
|
||||
$a = makeTicket(['number' => '1001']);
|
||||
$b = makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$a->id, $b->id]);
|
||||
});
|
||||
|
||||
test('toggleSelectAll deselects everything when all visible tickets are already selected', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-2@example.com');
|
||||
makeTicket(['number' => '1001']);
|
||||
makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', []);
|
||||
});
|
||||
|
||||
test('toggleSelectAll only affects tickets visible under the active filter, not every ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-3@example.com');
|
||||
makeTicket(['number' => '1001', 'status_key' => 'open']);
|
||||
$closed = makeTicket(['number' => '1002', 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('queue', 'closed')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$closed->id]);
|
||||
});
|
||||
@@ -45,7 +45,12 @@ test('with obfuscation on, the ticket URL and the displayed number both use the
|
||||
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertOk();
|
||||
$this->actingAs($operator)->get('/operator/tickets/1042')->assertNotFound();
|
||||
|
||||
// A "no ticket matches this identifier" route-binding failure now
|
||||
// redirects to the area's own queue instead of a bare 404 (see
|
||||
// bootstrap/app.php) — the raw sequential number still doesn't resolve
|
||||
// to the ticket, it just no longer surfaces as a dead-end error page.
|
||||
$this->actingAs($operator)->get('/operator/tickets/1042')->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () {
|
||||
|
||||
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
|
||||
test('guestReply records a client-role message with no author id', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Anonimowy Gość', 'Odpowiedź gościa e-mailem.');
|
||||
|
||||
expect($message->author_name)->toBe('Anonimowy Gość')
|
||||
->and($message->body)->toBe('Odpowiedź gościa e-mailem.')
|
||||
->and($message->author_id)->toBeNull()
|
||||
->and($message->role)->toBe('client')
|
||||
->and($message->source)->toBeNull();
|
||||
});
|
||||
|
||||
test('guestReply tags the message source as email when told to', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść', source: 'email');
|
||||
|
||||
expect($message->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('clientReply defaults to a null (web) source, and can be tagged as email', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
$webMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
$emailMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
expect($webMessage->source)->toBeNull()
|
||||
->and($emailMessage->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('guestReply updates last_customer_activity_at and clears automation logs, like clientReply', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['last_customer_activity_at' => now()->subDays(1)]);
|
||||
|
||||
$rule = AutomationRule::query()->create([
|
||||
'label' => 'Test rule',
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'set_priority',
|
||||
'action_value' => 'high',
|
||||
'enabled' => true,
|
||||
]);
|
||||
$ticket->automationRuleLogs()->create(['automation_rule_id' => $rule->id, 'triggered_at' => now()]);
|
||||
|
||||
app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść');
|
||||
|
||||
expect($ticket->fresh()->last_customer_activity_at->diffInSeconds(now()))->toBeLessThan(5)
|
||||
->and($ticket->automationRuleLogs()->count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user