CluPilotCloud/tests/Feature/Admin/MailTemplateTest.php

305 lines
13 KiB
PHP

<?php
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\EditMailTemplate;
use App\Livewire\Admin\MailTemplates;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\MailTemplate;
use App\Models\SentMail;
use App\Models\Subscription;
use App\Services\Mail\MailTemplateRenderer;
use App\Support\CompanyProfile;
use Livewire\Livewire;
/**
* The answers an operator gives over and over, written once.
*
* A template is INSERTED and then edited, never sent as it stands. So the thing
* worth testing is not "did a mail go out" but "did the right details end up in
* the field the operator is about to correct".
*/
beforeEach(fn () => CompanyProfile::put(['name' => 'CluPilot GmbH']));
it('fills a template with the customer own details', function () {
$customer = Customer::factory()->create([
'name' => 'Beispiel GmbH',
'contact_name' => 'Bea Berger',
'email' => 'bea@beispiel.test',
]);
Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'status' => 'active',
]);
$template = MailTemplate::create([
'name' => 'Datenübernahme',
'subject' => 'Ihre Anfrage — {{customer}}',
'body' => "Guten Tag {{contact}},\n\nzu Ihrem Paket {{plan}} für {{amount}}:\n\n{{operator}}\n{{company}}",
]);
$filled = app(MailTemplateRenderer::class)->render($template, $customer, operator('Owner'));
expect($filled['subject'])->toBe('Ihre Anfrage — Beispiel GmbH')
->and($filled['body'])->toContain('Guten Tag Bea Berger,')
->and($filled['body'])->toContain(__('billing.plan.team'))
->and($filled['body'])->toContain('CluPilot GmbH');
});
it('writes to the person where one is on record, and to the company otherwise', function () {
// "Guten Tag Beispiel GmbH" is how a mail announces that it came out of a
// database.
$withContact = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
$without = Customer::factory()->create(['name' => 'Zweite GmbH', 'contact_name' => null]);
$renderer = app(MailTemplateRenderer::class);
expect($renderer->values($withContact)['contact'])->toBe('Bea Berger')
->and($renderer->values($without)['contact'])->toBe('Zweite GmbH');
});
it('quotes the amount the customer knows, which is the gross one', function () {
// What the price sheet quoted them and what their bank statement says.
$customer = Customer::factory()->create();
Subscription::factory()->plan('start')->create([
'customer_id' => $customer->id,
'status' => 'active',
]);
$amount = app(MailTemplateRenderer::class)->values($customer)['amount'];
// 49 € net at 20 % → 58,80 €.
expect($amount)->toContain('58,80');
});
it('leaves a placeholder empty rather than inventing a package nobody bought', function () {
// A guess in a mail to a paying customer is worse than a gap the operator
// can see and fill.
$customer = Customer::factory()->create();
$values = app(MailTemplateRenderer::class)->values($customer);
expect($values['plan'])->toBe('')
->and($values['amount'])->toBe('')
->and($values['instance'])->toBe('');
});
it('leaves a typo standing so it is seen before anything is sent', function () {
// Replacing it with nothing produces "Guten Tag ," — a mail that looks sent
// by a machine, which is what templates exist to avoid.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create([
'name' => 'Mit Tippfehler',
'subject' => 'Test',
'body' => 'Guten Tag {{kunde}}, für {{customer}}.',
]);
$body = app(MailTemplateRenderer::class)->render($template, $customer)['body'];
expect($body)->toContain('{{kunde}}')
->and($body)->toContain('Beispiel GmbH');
});
it('tolerates a space inside the braces, because somebody will type one', function () {
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create(['name' => 'X', 'subject' => '{{ customer }}', 'body' => '{{ CUSTOMER }}']);
$filled = app(MailTemplateRenderer::class)->render($template, $customer);
expect($filled['subject'])->toBe('Beispiel GmbH')
->and($filled['body'])->toBe('Beispiel GmbH');
});
it('names the address of the cloud, where there is one', function () {
$customer = Customer::factory()->create();
Instance::factory()->create([
'customer_id' => $customer->id,
'subdomain' => 'kanzlei-berger',
'status' => 'active',
]);
expect(app(MailTemplateRenderer::class)->values($customer)['instance'])->toBe('kanzlei-berger');
});
// ---- Managing them ----
it('creates a template and offers it when writing', function () {
// Counted against what is already there: the installation ships a set, so an
// absolute count would only be testing the size of that set.
$before = MailTemplate::query()->count();
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailTemplates::class)
->set('name', 'Eigene Antwort')
->set('subject', 'Ihre Anfrage')
->set('body', 'Guten Tag {{contact}},')
->call('create')
->assertHasNoErrors();
expect(MailTemplate::query()->count())->toBe($before + 1)
->and(MailTemplate::query()->usable()->pluck('name'))->toContain('Eigene Antwort');
});
it('retires a template instead of deleting it', function () {
// An answer that is no longer given is still the answer somebody was given
// last year, and the mail register refers to it.
$template = MailTemplate::create(['name' => 'Alt', 'subject' => 'S', 'body' => 'B']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailTemplates::class)
->call('toggleActive', $template->uuid);
expect($template->fresh()->active)->toBeFalse()
// The row is still there — retired, not deleted.
->and(MailTemplate::query()->where('name', 'Alt')->exists())->toBeTrue()
->and(MailTemplate::query()->usable()->pluck('name'))->not->toContain('Alt');
});
it('edits a template in a modal, never in the row', function () {
// R20: a five-line textarea growing inside a list row pushes every column
// beside it and reads as a rendering fault.
$template = MailTemplate::create(['name' => 'Alt', 'subject' => 'S', 'body' => 'B']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(EditMailTemplate::class, ['uuid' => $template->uuid])
->set('name', 'Neu')
->set('body', 'Guten Tag {{contact}},')
->call('save');
expect($template->fresh()->name)->toBe('Neu')
->and($template->fresh()->body)->toContain('{{contact}}');
});
it('keeps the templates to operators who may write to customers', function () {
Livewire::actingAs(operator('Read-only'), 'operator')
->test(MailTemplates::class)
->assertForbidden();
});
// ---- Using one on the customer page ----
it('puts a template into the compose fields and opens that tab', function () {
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
$template = MailTemplate::create([
'name' => 'Datenübernahme',
'subject' => 'Ihre Anfrage — {{customer}}',
'body' => 'Guten Tag {{contact}},',
]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid);
expect($page->get('subject'))->toBe('Ihre Anfrage — Beispiel GmbH')
->and($page->get('body'))->toBe('Guten Tag Bea Berger,')
// Where the operator now has to be to finish it.
->and($page->get('tab'))->toBe('compose');
});
it('does not overwrite a subject the operator already typed', function () {
// A template that eats work is a template nobody uses twice.
$customer = Customer::factory()->create();
$template = MailTemplate::create(['name' => 'X', 'subject' => 'Aus der Vorlage', 'body' => 'Text']);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->set('subject', 'Selbst geschrieben')
->call('useTemplate', $template->uuid);
expect($page->get('subject'))->toBe('Selbst geschrieben')
->and($page->get('body'))->toBe('Text');
});
it('will not insert a retired template', function () {
$customer = Customer::factory()->create();
$template = MailTemplate::create(['name' => 'X', 'subject' => 'S', 'body' => 'B', 'active' => false]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid);
expect($page->get('body'))->toBe('');
});
it('sends what the operator saw, not what the template said', function () {
// The whole point of inserting rather than sending: the last two sentences
// are always specific to the person asking.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create(['name' => 'X', 'subject' => 'S', 'body' => 'Aus der Vorlage.']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid)
->set('body', 'Aus der Vorlage. Und mein eigener Satz.')
->call('send');
expect(SentMail::query()->where('customer_id', $customer->id)->latest('id')->first()->body)
->toBe('Aus der Vorlage. Und mein eigener Satz.');
});
// ---- The set that ships with the installation ----
it('ships the answers this business actually needs', function () {
// An empty list is a feature nobody starts using: writing the first template
// costs the same as writing the mail, so the operator writes the mail.
$names = MailTemplate::query()->usable()->pluck('name');
expect($names->count())->toBeGreaterThanOrEqual(10)
->and($names)->toContain('Datenübernahme — Rückfrage')
->and($names)->toContain('Störung — behoben')
->and($names)->toContain('Kündigung bestätigen');
});
it('promises nothing in a shipped template that nobody agreed to', function () {
// Every figure and every date is a gap, never a number: a seeded "innerhalb
// von 24 Stunden" is a promise put in the operator's mouth, and a seeded
// price is wrong for every customer but one.
foreach (MailTemplate::query()->get() as $template) {
expect($template->body)
->not->toMatch('/\b\d+\s*(Stunden|Werktagen|Tagen)\b/')
->not->toMatch('/\b\d+[,.]\d{2}\s*€/');
}
});
it('renders every shipped template without leaving a placeholder behind', function () {
// A token that the renderer does not know stays visible on purpose — so a
// shipped template must not contain one, or every operator meets a typo we
// wrote for them.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
Subscription::factory()->plan('start')->create(['customer_id' => $customer->id, 'status' => 'active']);
Instance::factory()->create(['customer_id' => $customer->id, 'subdomain' => 'beispiel', 'status' => 'active']);
$renderer = app(MailTemplateRenderer::class);
foreach (MailTemplate::query()->get() as $template) {
$filled = $renderer->render($template, $customer, operator('Owner'));
expect($filled['subject'])->not->toContain('{{', $template->name)
->and($filled['body'])->not->toContain('{{', $template->name);
}
});
it('stores a shipped body without the file own indentation in it', function () {
// Heredoc keeps the indentation of the source file, and a mail that arrives
// indented by twenty spaces looks like a mistake — because it is one.
foreach (MailTemplate::query()->get() as $template) {
expect($template->body)->not->toStartWith(' ')
->and($template->body)->not->toContain("\n ");
}
});
it('leaves an edited template alone when the seed runs again', function () {
// firstOrCreate on the name. A migration that overwrites text somebody
// worked on is the worst kind of surprise.
$template = MailTemplate::query()->where('name', 'Anfrage bestätigen')->firstOrFail();
$template->update(['body' => 'Meine eigene Fassung.']);
MailTemplate::query()->firstOrCreate(
['name' => 'Anfrage bestätigen'],
['subject' => 'Anders', 'body' => 'Anders', 'sort' => 0, 'active' => true],
);
expect($template->fresh()->body)->toBe('Meine eigene Fassung.')
->and(MailTemplate::query()->where('name', 'Anfrage bestätigen')->count())->toBe(1);
});