386 lines
17 KiB
PHP
386 lines
17 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\Subscription;
|
|
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.');
|
|
});
|
|
|
|
// ---- 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']));
|
|
}
|
|
});
|
|
|
|
it('does not invent a package for a customer who has booked nothing', function () {
|
|
// Reported straight after registering: the dashboard said "noch keine
|
|
// Instanz", and the package page said "AKTUELLES PAKET Start — Status Aktiv"
|
|
// about the same account. currentKey fell back to 'start', and the view
|
|
// rendered that fallback as a contract the customer had entered into.
|
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
|
Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(App\Livewire\Billing::class)
|
|
->assertViewHas('hasContract', false)
|
|
->assertSee(__('billing.none_title'))
|
|
// And no invented status beside a plan name.
|
|
->assertDontSee(__('billing.current_plan'));
|
|
});
|
|
|
|
it('shows the contract of a customer whose order is still waiting for a machine', function () {
|
|
// Resolved from the CUSTOMER, not from the instance: a paid order parked for
|
|
// want of capacity has a subscription and no instance yet, and asking the
|
|
// instance would report "no package" at somebody who has paid.
|
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
|
$customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
|
|
Subscription::factory()->plan('team')->create([
|
|
'customer_id' => $customer->id,
|
|
'status' => 'active',
|
|
'instance_id' => null,
|
|
]);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(App\Livewire\Billing::class)
|
|
->assertViewHas('hasContract', true)
|
|
->assertSee(__('billing.plan.team'))
|
|
->assertDontSee(__('billing.none_title'));
|
|
});
|