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); });