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); }); // ---- Moving one, and seeing that it moved ---- it('swaps a template with its neighbour rather than nudging a number', function () { // It used to add or subtract a fixed amount, which is not the same thing: // with values ten apart that usually landed right, and sometimes landed on a // value another template already had — equal sort falls back to the name, so // a click could reorder something else, or visibly nothing at all, which is // indistinguishable from a click that never registered. MailTemplate::query()->delete(); $first = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 10]); $second = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 20]); $third = MailTemplate::create(['name' => 'C', 'subject' => 'S', 'body' => 'B', 'sort' => 30]); $order = fn () => MailTemplate::query()->orderBy('sort')->orderBy('name')->pluck('name')->all(); $page = Livewire::actingAs(operator('Owner'), 'operator')->test(MailTemplates::class); $page->call('move', $third->uuid, -1); expect($order())->toBe(['A', 'C', 'B']); $page->call('move', $third->uuid, -1); expect($order())->toBe(['C', 'A', 'B']); $page->call('move', $third->uuid, 1); expect($order())->toBe(['A', 'C', 'B']); }); it('does nothing at the ends instead of pretending', function () { // Both are what an operator expects to see: the top one cannot go up. A // number that silently changed without the order changing was the bug. MailTemplate::query()->delete(); $top = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 10]); $bottom = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 20]); $page = Livewire::actingAs(operator('Owner'), 'operator')->test(MailTemplates::class); $page->call('move', $top->uuid, -1); $page->call('move', $bottom->uuid, 1); expect(MailTemplate::query()->orderBy('sort')->pluck('name')->all())->toBe(['A', 'B']) ->and($top->fresh()->sort)->toBe(10) ->and($bottom->fresh()->sort)->toBe(20); }); it('separates two templates that shared a sort value', function () { // Equal values put the order in the hands of the name, so a swap between // them has to leave them distinct or the next click does nothing. MailTemplate::query()->delete(); $a = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 5]); $b = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 5]); Livewire::actingAs(operator('Owner'), 'operator') ->test(MailTemplates::class) ->call('move', $b->uuid, -1); expect($a->fresh()->sort)->not->toBe($b->fresh()->sort) ->and(MailTemplate::query()->orderBy('sort')->orderBy('name')->pluck('name')->all())->toBe(['B', 'A']); }); it('says a click landed, on the button and at the top of the window', function () { // Reported as "man klickt und sieht nicht was passiert". Every action here is // a round trip; a round trip with no sign of itself reads as a click that // missed. The bar is the floor under every page, the per-button state says // WHICH thing is happening. $page = File::get(resource_path('views/livewire/admin/mail-templates.blade.php')); expect($page)->toContain('wire:loading.attr="disabled"') ->toContain('animate-spin') // Scoped to the one button, not to the whole page: every row disabling // itself because a neighbour is moving is its own kind of confusing. ->toContain('wire:target="move('); expect(File::get(resource_path('js/app.js'))) ->toContain('BUSY_SHOW_AFTER_MS') // Released on failure too — the request somebody must not be left // waiting on is exactly the one that went wrong. ->toContain('respond(() => busy.stop())'); });