Files
servicedesk/src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Kacper 0d116dfd98 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>
2026-07-23 12:44:30 +02:00

204 lines
7.9 KiB
PHP

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