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.'); }); // ---- Two things the console owed the operator ---- it('says on the front page that the site is switched off', function () { // Asked as "ich komme nicht auf www.dev". The site answers 503 to every // visitor who is not on the VPN, which is exactly what a broken deployment // looks like from outside — and the only place that said so was the switch // itself, on another page. App\Support\Settings::set('site.public', false); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee(__('admin.notice.site_hidden')) // And it leads to the switch. ->assertSee(route('admin.settings'), false); App\Support\Settings::set('site.public', true); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertDontSee(__('admin.notice.site_hidden')); }); it('says when the website has no hostname of its own', function () { // Without SITE_HOST the marketing site is registered without a hostname, so // it answers on the PORTAL's host too — and every route() it generates // points at APP_URL. That is why a link on the site lands on app.…, which // was the other half of the same report. // Only fires where the installation is host-separated at all: on a fresh // checkout nothing is bound, and a warning there would be furniture. config()->set('admin_access.hosts', ['admin.dev.example.test']); config()->set('admin_access.site_hosts', []); config()->set('app.url', 'https://app.dev.example.test'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee('app.dev.example.test'); config()->set('admin_access.site_hosts', ['www.dev.example.test']); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertDontSee(__('admin.notice.no_site_host', ['app' => 'app.dev.example.test'])); }); it('says when no mail is being delivered at all', function () { // The report: "ich versuche mich zu registrieren, erhalte aber keine Verify- // Mail, auch nicht beim erneuten Senden". MAIL_MAILER=log short-circuits // every purpose mailer to the log transport (MailboxTransport:: // NON_DELIVERING) however well the mailboxes are configured — and the // failure is silent in every direction: the queue reports success, no job // fails, and the console's own mailbox test reports success too because it // builds its own transport on purpose. Nothing on any page said so. config()->set('mail.default', 'log'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])) ->assertSee(route('admin.mail'), false); // And on the page somebody actually opens to find out why. Livewire::actingAs(operator('Owner'), 'operator') ->test(App\Livewire\Admin\Mail::class) ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])); }); it('stays quiet once mail really goes out', function () { config()->set('mail.default', 'smtp'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertDontSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])); }); it('names every mailer that swallows mail, not only the log one', function () { // array and an unset default do the same thing, and the transport already // treats all three alike. A notice that only knew about 'log' would be // silent in the two cases nobody thinks to check. $swallowing = (new ReflectionClass(App\Mail\Transport\MailboxTransport::class)) ->getConstant('NON_DELIVERING'); expect($swallowing)->toBe(['log', 'array', null]); foreach (['array', null] as $mailer) { config()->set('mail.default', $mailer); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => $mailer ?? 'null'])); } });