CluPilotCloud/tests/Feature/Admin/MailRegisterTest.php

259 lines
11 KiB
PHP

<?php
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\MailLog;
use App\Livewire\Admin\Overview;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\SentMail;
use App\Models\SupportRequest;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
/**
* What left the building, and who wrote it.
*
* "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the
* mail server until now — a different machine, a different program, and nothing
* an operator can put in front of somebody who says nothing arrived.
*/
it('writes a row for every mail the application sends', function () {
$customer = Customer::factory()->create();
Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text'));
$row = SentMail::query()->latest('id')->first();
expect($row)->not->toBeNull()
->and($row->to)->toBe($customer->email)
->and($row->subject)->toBe('Betreff')
->and($row->mailable)->toBe(OperatorMessageMail::class)
// Attached to the customer, so the question "what did THIS customer
// get" does not mean reading the whole register.
->and($row->customer_id)->toBe($customer->id);
});
it('records the send, not the intention', function () {
// On MessageSent, not MessageSending: a row here has to mean the transport
// accepted it. Recording intentions would fill the register with mails that
// never left — worse than no register, because an operator would show a
// customer a line proving something was sent that was not.
expect(config('mail.default'))->not->toBeNull();
$before = SentMail::query()->count();
Mail::fake();
$customer = Customer::factory()->create();
Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text'));
// Mail::fake() never hands the message to a transport, so nothing was sent
// and nothing is claimed.
expect(SentMail::query()->count())->toBe($before);
});
it('keeps a mail to an address nobody owns, without inventing a customer for it', function () {
Mail::to('fremder@example.test')->send(
new OperatorMessageMail(Customer::factory()->create(), 'Betreff', 'Text')
);
$row = SentMail::query()->where('to', 'fremder@example.test')->first();
expect($row)->not->toBeNull()
->and($row->customer_id)->toBeNull();
});
it('does not let a stranger write rows by asking for a password reset', function () {
// The verification and reset mails go to addresses nobody has confirmed,
// and anyone who can type into a form can send them. Recording them would
// hand the operator's register to whoever wants to fill it.
$user = App\Models\User::factory()->create();
Mail::to($user->email)->send(new App\Mail\ResetPasswordMail($user, 'https://example.test/reset', 60));
expect(SentMail::query()->where('to', $user->email)->exists())->toBeFalse();
});
it('shows the register, newest first, with the customer it belongs to', function () {
$customer = Customer::factory()->create(['name' => 'Kanzlei Berger']);
SentMail::create([
'customer_id' => $customer->id,
'to' => $customer->email,
'subject' => 'Ihre Zugangsdaten',
'mailable' => App\Mail\OperatorMessageMail::class,
'sent_at' => now()->subHour(),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailLog::class)
->assertOk()
->assertSee('Ihre Zugangsdaten')
->assertSee('Kanzlei Berger');
});
// ---- Writing to a customer from the console ----
it('sends the operator message and records what was written', function () {
// The body is kept only for a mail a person typed: "what exactly did I
// write to them in March" is a question the mail server's log cannot
// answer either.
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->set('subject', 'Zur Datenübernahme')
->set('body', 'Wir haben uns Ihr System angesehen und können übernehmen.')
->call('send');
$row = SentMail::query()->where('customer_id', $customer->id)->latest('id')->first();
expect($row)->not->toBeNull()
->and($row->subject)->toBe('Zur Datenübernahme')
->and($row->from_operator)->toBeTrue()
->and($row->body)->toBe('Wir haben uns Ihr System angesehen und können übernehmen.');
});
it('closes the request it answers, which is the reason to answer from here', function () {
// Answering from a mail client leaves the request open for ever, because
// nothing ever told it otherwise.
$customer = Customer::factory()->create();
$request = SupportRequest::create([
'customer_id' => $customer->id,
'subject' => 'Können Sie unsere Daten übernehmen?',
'category' => 'other',
'body' => 'Wir haben ownCloud 10 im Haus.',
'status' => 'open',
'reported_by' => $customer->email,
]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('answer', $request->uuid);
expect($page->get('subject'))->toContain('Können Sie unsere Daten übernehmen?');
$page->set('body', 'Ja, das geht — Angebot folgt.')->call('send');
expect($request->fresh()->status)->toBe('answered')
->and($request->fresh()->answered_at)->not->toBeNull();
});
it('shows the customer their own words, so nobody opens the mail client to read the question', function () {
$customer = Customer::factory()->create();
SupportRequest::create([
'customer_id' => $customer->id,
'subject' => 'Frage zur Migration',
'category' => 'other',
'body' => 'Wir haben 42 GB in ownCloud und wollen wechseln.',
'status' => 'open',
'reported_by' => $customer->email,
]);
// On the messages tab since the page was rebuilt around tabs: the details
// are the first thing an operator needs, the conversation the second.
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertSee('Wir haben 42 GB in ownCloud und wollen wechseln.');
});
it('refuses an empty message rather than sending one', function () {
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('send')
->assertHasErrors(['subject', 'body']);
expect(SentMail::query()->where('customer_id', $customer->id)->exists())->toBeFalse();
});
it('keeps both pages to operators who may see customers', function () {
// Support KEEPS both, deliberately: answering customers is the job, and a
// support operator who cannot read what was already sent them is answering
// blind. Read-only is the role that may look at the console and change
// nothing — writing a mail is a change, and so is reading the register of
// what was written.
$customer = Customer::factory()->create();
expect(operator('Support')->can('customers.manage'))->toBeTrue();
Livewire::actingAs(operator('Read-only'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertForbidden();
Livewire::actingAs(operator('Read-only'), 'operator')
->test(MailLog::class)
->assertForbidden();
});
// ---- The notices somebody can finally act on ----
it('gives every notice the page that shows the thing it is about', function () {
// The count in the header said "4 Hinweis(e)" and led nowhere: whoever read
// it had to know the list was a card further down the same page, and then
// go looking for the host or the run by hand.
App\Models\Host::factory()->create(['status' => 'error', 'name' => 'pve-broken']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertViewHas('notices', fn (array $notices) => collect($notices)
->every(fn (array $n) => array_key_exists('route', $n) && $n['route'] !== null))
->assertSee(route('admin.hosts'), false);
});
it('counts notices in words, not in brackets', function () {
expect(trans_choice('admin.systems_notices', 1, ['n' => 1]))->toBe('1 Hinweis')
->and(trans_choice('admin.systems_notices', 4, ['n' => 4]))->toBe('4 Hinweise');
});
it('shows one customer their own things and never another customer', function () {
// Reported as "unterhalb steht immer das selbe, egal bei welchem Kunden".
// Worth proving rather than assuming: every list on this page is scoped to
// the customer in the URL, and two customers side by side must not bleed
// into each other.
$one = Customer::factory()->create(['name' => 'Erste Kanzlei']);
$two = Customer::factory()->create(['name' => 'Zweite Kanzlei']);
SupportRequest::create([
'customer_id' => $one->id, 'subject' => 'Frage der Ersten', 'category' => 'other',
'body' => 'Text der Ersten', 'status' => 'open', 'reported_by' => $one->email,
]);
SupportRequest::create([
'customer_id' => $two->id, 'subject' => 'Frage der Zweiten', 'category' => 'other',
'body' => 'Text der Zweiten', 'status' => 'open', 'reported_by' => $two->email,
]);
SentMail::create(['customer_id' => $one->id, 'to' => $one->email, 'subject' => 'Post an die Erste', 'sent_at' => now()]);
SentMail::create(['customer_id' => $two->id, 'to' => $two->email, 'subject' => 'Post an die Zweite', 'sent_at' => now()]);
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $one->uuid])
->assertSee('Frage der Ersten')
->assertSee('Post an die Erste')
->assertDontSee('Frage der Zweiten')
->assertDontSee('Post an die Zweite');
});
it('names the status in words rather than printing the key at it', function () {
// The badge read "customers.status.active" — a lang key with no file behind
// it renders as itself, and Laravel says nothing about it.
$customer = Customer::factory()->create(['status' => 'active']);
SupportRequest::create([
'customer_id' => $customer->id, 'subject' => 'Frage', 'category' => 'other',
'body' => 'Text', 'status' => 'open', 'reported_by' => $customer->email,
]);
// The status badge is in the page header, on every tab; the request status
// lives with the requests.
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertSee(__('admin.status.active'))
->assertSee(__('support.status_open'))
// Neither key leaks through as its own name.
->assertDontSee('customers.status.')
->assertDontSee('support.status.');
});