feat: admin editable email templates
addresses #869eg7ptr
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Config;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The delivered wording is what every mail falls back to, and it is also the text an admin
|
||||
* starts editing from. A default that references a placeholder nobody declared would print
|
||||
* a dash forever without anyone noticing, which is what these tests are here to prevent.
|
||||
*
|
||||
* Since the wording moved to config/email_texts.yaml, building every definition here is
|
||||
* also what proves that file is complete: a missing entry or a placeholder without a
|
||||
* description fails in build() before any assertion runs.
|
||||
*/
|
||||
class EmailTextCatalogTest extends KernelTestCase
|
||||
{
|
||||
public function testEveryKeyHasADefinition(): void
|
||||
{
|
||||
$catalog = $this->catalog();
|
||||
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
$definition = $catalog->get($key);
|
||||
|
||||
$this->assertSame($key, $definition->key);
|
||||
$this->assertNotSame('', $definition->label);
|
||||
$this->assertNotSame('', $definition->defaultSubject);
|
||||
$this->assertNotSame('', $definition->defaultBody);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDefaultsOnlyUseDeclaredPlaceholders(): void
|
||||
{
|
||||
$catalog = $this->catalog();
|
||||
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
$definition = $catalog->get($key);
|
||||
$declared = $definition->getPlaceholderNames();
|
||||
|
||||
preg_match_all(
|
||||
'/\{(\w+)\}/',
|
||||
$definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody,
|
||||
$matches
|
||||
);
|
||||
$used = array_unique($matches[1]);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
array_diff($used, $declared),
|
||||
sprintf('Default wording of "%s" uses undeclared placeholders', $key->value)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder nobody prints is either a leftover or a hint that the wording lost a
|
||||
* detail it used to carry.
|
||||
*/
|
||||
public function testEveryDeclaredPlaceholderIsUsedByTheDefaults(): void
|
||||
{
|
||||
$catalog = $this->catalog();
|
||||
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
$definition = $catalog->get($key);
|
||||
$text = $definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody;
|
||||
|
||||
foreach ($definition->getPlaceholderNames() as $name) {
|
||||
$this->assertStringContainsString(
|
||||
'{'.$name.'}',
|
||||
$text,
|
||||
sprintf('Placeholder "%s" of "%s" is declared but never used', $name, $key->value)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testEveryPlaceholderIsDescribedForTheAdmin(): void
|
||||
{
|
||||
$catalog = $this->catalog();
|
||||
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
foreach ($catalog->get($key)->placeholders as $name => $description) {
|
||||
$this->assertNotSame('', trim($description), sprintf('Placeholder "%s" has no description', $name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function catalog(): EmailTextCatalog
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
return self::getContainer()->get(EmailTextCatalog::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Email;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Email\EmailTextRenderer;
|
||||
use App\Email\Mailer;
|
||||
use App\Repository\EmailTextRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Renders every editable mail through the real template stack.
|
||||
*
|
||||
* The unit tests cover what the renderer produces; this covers that the result actually
|
||||
* survives email/generic.html.twig and the layout around it - a mail that only breaks
|
||||
* when it is assembled would otherwise go unnoticed until a teamer fails to receive it.
|
||||
*/
|
||||
class EmailTextMailRenderingTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideKeys
|
||||
*/
|
||||
public function testTheMailRenders(EmailTextKey $key): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = self::getContainer();
|
||||
$catalog = $container->get(EmailTextCatalog::class);
|
||||
$definition = $catalog->get($key);
|
||||
|
||||
$placeholders = [];
|
||||
|
||||
foreach ($definition->getPlaceholderNames() as $name) {
|
||||
$placeholders[$name] = '['.$name.']';
|
||||
}
|
||||
|
||||
// Renders the delivered wording: the test environment has no database, and the
|
||||
// defaults are what a mail falls back to anyway.
|
||||
$repository = $this->createMock(EmailTextRepository::class);
|
||||
$repository->method('findByKey')->willReturn(null);
|
||||
|
||||
$text = (new EmailTextRenderer($catalog, $repository))
|
||||
->render($key, $placeholders)
|
||||
;
|
||||
|
||||
$email = $container->get(Mailer::class)->create(['text' => $text], [
|
||||
'from' => '[email protected]',
|
||||
'to' => '[email protected]',
|
||||
'subject' => $text->subject,
|
||||
'template' => 'email/generic.html.twig',
|
||||
'subject_parameters' => [],
|
||||
'attachments' => [],
|
||||
'transport' => null,
|
||||
'bus_transport' => null,
|
||||
]);
|
||||
|
||||
$html = $email->getHtmlBody();
|
||||
|
||||
$this->assertNotEmpty($html);
|
||||
$this->assertStringContainsString($text->headline, $html);
|
||||
// The layout closes every mail with the same button, pointing at the site root
|
||||
// rather than a deep link so that a client's link preview cannot trip the
|
||||
// unauthorized-access logging.
|
||||
$this->assertStringContainsString('class="button">Zum Portal</a>', $html);
|
||||
$this->assertStringNotContainsString('/teamer/disposition/detail', $html);
|
||||
|
||||
// Every placeholder the mail declares has to reach the rendered body, or the
|
||||
// wording lost a detail somewhere between catalogue and template.
|
||||
foreach ($definition->getPlaceholderNames() as $name) {
|
||||
$this->assertStringContainsString(
|
||||
'['.$name.']',
|
||||
$html.$email->getSubject(),
|
||||
sprintf('Placeholder "%s" does not show up in the rendered mail', $name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideKeys(): iterable
|
||||
{
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
yield $key->value => [$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Email;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Email\EmailTextRenderer;
|
||||
use App\Entity\EmailText;
|
||||
use App\Repository\EmailTextRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The renderer is the only thing standing between text an admin (or a teamer) typed and
|
||||
* the HTML of a mail, so most of what is asserted here is that nothing but the renderer
|
||||
* itself can introduce a tag.
|
||||
*/
|
||||
class EmailTextRendererTest extends KernelTestCase
|
||||
{
|
||||
public function testTheStoredWordingWinsOverTheDefault(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(subject: 'Neuer Betreff', body: 'Neuer Text'),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertSame('Neuer Betreff', $rendered->subject);
|
||||
$this->assertSame('<p>Neuer Text</p>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh database has no rows at all, so the delivered wording has to carry the mails
|
||||
* on its own.
|
||||
*/
|
||||
public function testTheDefaultWordingIsUsedWithoutAStoredRow(): void
|
||||
{
|
||||
$rendered = $this->render(null, ['destination' => 'Skireise']);
|
||||
|
||||
$this->assertSame('Deine Bewerbung wurde abgelehnt', $rendered->subject);
|
||||
$this->assertStringContainsString('Skireise', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testMarkupTypedByAnAdminIsEscaped(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
|
||||
$this->assertStringContainsString('<script>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* destination, reason and comment are all typed by people, not by the application.
|
||||
*/
|
||||
public function testMarkupInsideAPlaceholderValueIsEscaped(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Einsatz {destination}'),
|
||||
['destination' => '<img src=x onerror=alert(1)>']
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString('<img', $rendered->bodyHtml);
|
||||
$this->assertStringContainsString('<img', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testAsterisksMakeTextBold(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Das ist *wichtig* und das nicht'),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertSame('<p>Das ist <strong>wichtig</strong> und das nicht</p>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testAnEscapedAsteriskStaysLiteral(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Ein Sternchen: \*'),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertSame('<p>Ein Sternchen: *</p>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emphasis is the admin's to give. If a teamer could hand one over in a rejection
|
||||
* comment, the substitution order would be wrong and escaping would be next.
|
||||
*/
|
||||
public function testAsterisksInsideAPlaceholderValueStayLiteral(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Einsatz {destination}'),
|
||||
['destination' => '*nicht fett*']
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
|
||||
$this->assertStringContainsString('*nicht fett*', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testAnEmptyPlaceholderFallsBackToADash(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Absprachen: {destination}'),
|
||||
['destination' => ' ']
|
||||
);
|
||||
|
||||
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller that forgets a placeholder should print the same dash, not the raw token.
|
||||
*/
|
||||
public function testAMissingPlaceholderFallsBackToADash(): void
|
||||
{
|
||||
$rendered = $this->render($this->emailText(body: 'Absprachen: {destination}'), []);
|
||||
|
||||
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testBlankLinesBecomeParagraphsAndSingleBreaksStay(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: "Erste Zeile\nZweite Zeile\n\nNeuer Absatz"),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
"<p>Erste Zeile<br />\nZweite Zeile</p>\n<p>Neuer Absatz</p>",
|
||||
$rendered->bodyHtml
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejection comment arrives as one multi-line value inside a single paragraph.
|
||||
*/
|
||||
public function testLineBreaksInsideAPlaceholderValueSurvive(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: '{destination}'),
|
||||
['destination' => "Erste Zeile\nZweite Zeile"]
|
||||
);
|
||||
|
||||
$this->assertSame("<p>Erste Zeile<br />\nZweite Zeile</p>", $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
public function testMailAddressesAndUrlsBecomeLinks(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(body: 'Schreib an [email protected] oder https://ep-reisen.de/team.'),
|
||||
['destination' => 'Skireise']
|
||||
);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'<a href="mailto:[email protected]">[email protected]</a>',
|
||||
$rendered->bodyHtml
|
||||
);
|
||||
// The full stop ends the sentence, it is not part of the address.
|
||||
$this->assertStringContainsString(
|
||||
'<a href="https://ep-reisen.de/team">https://ep-reisen.de/team</a>.',
|
||||
$rendered->bodyHtml
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail clients show the subject as plain text, so escaping it would leak entities
|
||||
* into the inbox.
|
||||
*/
|
||||
public function testTheSubjectIsPlainText(): void
|
||||
{
|
||||
$rendered = $this->render(
|
||||
$this->emailText(subject: 'Einsatz {destination}'),
|
||||
['destination' => 'Ski & Snowboard']
|
||||
);
|
||||
|
||||
$this->assertSame('Einsatz Ski & Snowboard', $rendered->subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|null> $placeholders
|
||||
*/
|
||||
private function render(?EmailText $emailText, array $placeholders): \App\Model\RenderedEmailTextDto
|
||||
{
|
||||
$repository = $this->createMock(EmailTextRepository::class);
|
||||
$repository
|
||||
->method('findByKey')
|
||||
->willReturn($emailText)
|
||||
;
|
||||
|
||||
self::bootKernel();
|
||||
|
||||
$renderer = new EmailTextRenderer(
|
||||
self::getContainer()->get(EmailTextCatalog::class),
|
||||
$repository
|
||||
);
|
||||
|
||||
return $renderer->render(EmailTextKey::APPLICATION_REJECTED, $placeholders);
|
||||
}
|
||||
|
||||
private function emailText(string $subject = 'Betreff', string $body = 'Text'): EmailText
|
||||
{
|
||||
return (new EmailText(EmailTextKey::APPLICATION_REJECTED))
|
||||
->setSubject($subject)
|
||||
->setBody($body)
|
||||
->setHeadline('')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Email\MailPlaceholderFactory;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
@@ -37,6 +38,8 @@ class EmailNotificationSubscriberTest extends TestCase
|
||||
$this->mailer,
|
||||
$this->createMock(UserRepository::class),
|
||||
$this->createMock(ContractRenderer::class),
|
||||
$this->createMock(MailPlaceholderFactory::class),
|
||||
7,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +47,7 @@ class EmailNotificationSubscriberTest extends TestCase
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createDeletedTeamer());
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
$this->mailer->expects($this->never())->method('createAndSendText');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
@@ -53,7 +56,7 @@ class EmailNotificationSubscriberTest extends TestCase
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createTeamer());
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
$this->mailer->expects($this->once())->method('createAndSendText');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
@@ -63,7 +66,7 @@ class EmailNotificationSubscriberTest extends TestCase
|
||||
$application = new Application(new Assignment(), $this->createDeletedTeamer());
|
||||
$application->setStatus(Application::STATUS_REJECTED);
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
$this->mailer->expects($this->never())->method('createAndSendText');
|
||||
|
||||
$this->subscriber->onApplicationStatus(
|
||||
new ApplicationStatusEvent(new ApplicationStatusDto($application))
|
||||
@@ -80,7 +83,7 @@ class EmailNotificationSubscriberTest extends TestCase
|
||||
$assignment->addDisposition($this->createDisposition($this->createTeamer(), $assignment));
|
||||
$assignment->addDisposition($this->createDisposition($this->createDeletedTeamer(), $assignment));
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
$this->mailer->expects($this->once())->method('createAndSendText');
|
||||
|
||||
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Entity\EmailText;
|
||||
use App\Form\EmailTextType;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
class EmailTextTypeTest extends KernelTestCase
|
||||
{
|
||||
public function testTheDeliveredWordingIsAcceptedUnchanged(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$definition = self::getContainer()->get(EmailTextCatalog::class)->get(EmailTextKey::APPLICATION_ACCEPTED);
|
||||
|
||||
$form = $this->submit(EmailTextKey::APPLICATION_ACCEPTED, [
|
||||
'subject' => $definition->defaultSubject,
|
||||
'headline' => $definition->defaultHeadline,
|
||||
'body' => $definition->defaultBody,
|
||||
]);
|
||||
|
||||
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* A typo in a placeholder would otherwise reach a teamer's inbox as a dash, with
|
||||
* nothing anywhere pointing at the cause.
|
||||
*/
|
||||
public function testAnUnknownPlaceholderIsRejected(): void
|
||||
{
|
||||
$form = $this->submit(EmailTextKey::APPLICATION_ACCEPTED, [
|
||||
'body' => 'Dein Einsatz {destinaton} wurde angenommen',
|
||||
]);
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
$this->assertStringContainsString(
|
||||
'{destinaton}',
|
||||
(string) $form->get('body')->getErrors()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholders are per mail: the rejection mail has no special agreements to print.
|
||||
*/
|
||||
public function testAPlaceholderOfAnotherMailIsRejected(): void
|
||||
{
|
||||
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, [
|
||||
'body' => 'Absprachen: {specialAgreements}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
}
|
||||
|
||||
public function testTheSubjectIsCheckedForPlaceholdersToo(): void
|
||||
{
|
||||
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, [
|
||||
'subject' => 'Einsatz {unbekannt}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* A mail may open straight with its text, so the headline is optional.
|
||||
*/
|
||||
public function testTheHeadlineMayBeEmpty(): void
|
||||
{
|
||||
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, ['headline' => '']);
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $data
|
||||
*/
|
||||
private function submit(EmailTextKey $key, array $data): FormInterface
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$definition = self::getContainer()->get(EmailTextCatalog::class)->get($key);
|
||||
|
||||
$form = self::getContainer()
|
||||
->get(FormFactoryInterface::class)
|
||||
->create(EmailTextType::class, new EmailText($key), [
|
||||
'definition' => $definition,
|
||||
'csrf_protection' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$form->submit($data + [
|
||||
'subject' => $definition->defaultSubject,
|
||||
'headline' => $definition->defaultHeadline,
|
||||
'body' => $definition->defaultBody,
|
||||
]);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user