feat: answer received form submissions by mail
This commit is contained in:
1 parent
19364df2db
commit
aa728ddc99
22 files changed
+974
-9
No files matched your search
@@ -70,4 +70,35 @@ class SeasonCalendarTest extends TestCase
|
||||
|
||||
(new SeasonCalendar('xx-yy', '04-01'))->seasonYearFor(new \DateTimeImmutable('2025-01-01'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideUpcomingSeasonDates
|
||||
*/
|
||||
public function testTheCurrentOrUpcomingSeasonYear(string $date, int $expected): void
|
||||
{
|
||||
$calendar = new SeasonCalendar('11-01', '04-01');
|
||||
|
||||
$this->assertSame($expected, $calendar->currentOrUpcomingSeasonYear(new \DateTimeImmutable($date)));
|
||||
}
|
||||
|
||||
public static function provideUpcomingSeasonDates(): iterable
|
||||
{
|
||||
// Between two seasons: the winter ahead, which is what a recruiting mail means.
|
||||
yield 'September, before the season starts' => ['2026-09-24', 2026];
|
||||
yield 'the day the season starts' => ['2026-11-01', 2026];
|
||||
// Inside a season: that season, not the next one.
|
||||
yield 'December, mid season' => ['2026-12-15', 2026];
|
||||
yield 'the last day of the season' => ['2027-04-01', 2026];
|
||||
// After a season ended, so the next one to start.
|
||||
yield 'May, after the season ended' => ['2027-05-02', 2027];
|
||||
}
|
||||
|
||||
public function testTheShortLabelIsTheTwoDigitSpan(): void
|
||||
{
|
||||
$calendar = new SeasonCalendar('11-01', '04-01');
|
||||
|
||||
$this->assertSame('26/27', $calendar->shortLabel(2026));
|
||||
// The turn of the century has to stay two digits on both ends.
|
||||
$this->assertSame('99/00', $calendar->shortLabel(1999));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The return url of a detail page's own actions.
|
||||
*
|
||||
* Every action on this page has to hand on the "r" it was given, not the url of the page it sits
|
||||
* on: return_url() encodes the current request, so a send button would send the reviewer back to
|
||||
* the detail page they are already looking at - and because the send controller redirects with
|
||||
* that same "r", "zurück" then points at the detail page itself and appears to do nothing, one
|
||||
* nesting level deeper per click. forwarded_return_url() is what keeps pointing at the list.
|
||||
*/
|
||||
class DetailControllerTest extends WebTestCase
|
||||
{
|
||||
private const LIST_URL = '/admin/application-form-submission';
|
||||
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', ApplicationFormSubmission::class))->execute();
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute();
|
||||
|
||||
$this->client->loginUser($this->reviewer());
|
||||
}
|
||||
|
||||
public function testTheActionsCarryTheReturnUrlTheyWereGivenOn(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
sprintf('/admin/application-form-submission/detail/%s', $submission->getUuid()),
|
||||
['r' => rawurlencode(self::LIST_URL)]
|
||||
);
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
// "zurück" goes where the reviewer came from.
|
||||
$this->assertSame(self::LIST_URL, $crawler->filter('a.btn')->first()->attr('href'));
|
||||
|
||||
foreach (['send-receipt', 'send-follow-up'] as $action) {
|
||||
$button = $crawler->filter(sprintf('button[hx-get*="%s"]', $action));
|
||||
|
||||
$this->assertCount(1, $button, sprintf('The %s button is missing', $action));
|
||||
|
||||
$url = $button->attr('hx-get');
|
||||
|
||||
$this->assertStringContainsString('r='.rawurlencode(self::LIST_URL), $url);
|
||||
// The bug: the detail url itself as the return url, which "zurück" then loops on.
|
||||
$this->assertStringNotContainsString('detail', urldecode(substr($url, (int) strpos($url, 'r='))));
|
||||
}
|
||||
}
|
||||
|
||||
public function testWithoutAReturnUrlTheActionsCarryNone(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
sprintf('/admin/application-form-submission/detail/%s', $submission->getUuid())
|
||||
);
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
// Dropped rather than emitted empty, so the controllers fall back to the list route.
|
||||
$this->assertStringNotContainsString('r=', $crawler->filter('button[hx-get*="send-receipt"]')->attr('hx-get'));
|
||||
$this->assertSame(self::LIST_URL, $crawler->filter('a.btn')->first()->attr('href'));
|
||||
}
|
||||
|
||||
private function submission(): ApplicationFormSubmission
|
||||
{
|
||||
$submission = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00')
|
||||
))->setEmail('[email protected]');
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function reviewer(): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Rita')
|
||||
->setLastName('Reviewer')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAM_ADMIN'])
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(1)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,107 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertSame(2, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt mail only goes out for an applicant the form reported as EU (incl. Switzerland).
|
||||
* Everything else - "no", and a form that never asks - is silence, so an unmapped field can
|
||||
* never mail someone by accident.
|
||||
*/
|
||||
public function testTheReceiptGoesOutForAnEuApplicant(): void
|
||||
{
|
||||
$this->post($this->eligiblePayload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(1);
|
||||
$this->assertNotNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
$this->assertTrue($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
public function testNoReceiptForANonEuApplicant(): void
|
||||
{
|
||||
$this->post(['eu' => false] + $this->eligiblePayload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
public function testNoReceiptWhenTheFormDoesNotAsk(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['eu']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertNull($this->lastSubmission()->isEu());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A form posts "1" as readily as a JSON true, and dropping an application over the spelling
|
||||
* would be absurd - the same tolerance pageUid has.
|
||||
*/
|
||||
public function testAQuotedEuFlagStillEarnsTheReceipt(): void
|
||||
{
|
||||
$this->post(['eu' => '1'] + $this->eligiblePayload());
|
||||
|
||||
$this->assertEmailCount(1);
|
||||
$this->assertTrue($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
public function testAnEmptyEuFlagIsNotAYes(): void
|
||||
{
|
||||
$this->post(['eu' => ''] + $this->eligiblePayload());
|
||||
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
/**
|
||||
* Not a silent null: a mapping that quietly stopped every receipt mail is worse than a 400
|
||||
* the TYPO3 side logs.
|
||||
*/
|
||||
public function testAnUnparseableEuFlagIsRejected(): void
|
||||
{
|
||||
$this->post(['eu' => 'vielleicht'] + $this->eligiblePayload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
$this->assertEmailCount(0);
|
||||
}
|
||||
|
||||
public function testAnEligibleSubmissionWithoutAnEmailAddressIsStillStored(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['email']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertSame(1, $this->countSubmissions());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function eligiblePayload(): array
|
||||
{
|
||||
return [
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'firstName' => 'Anna',
|
||||
'lastName' => 'Muster',
|
||||
'email' => '[email protected]',
|
||||
'eu' => true,
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,29 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
*/
|
||||
class EmailTextMailRenderingTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* The mails that go to someone without an account here, and are therefore sent through the
|
||||
* template that empties the layout's closing "Zum Portal" button. Kept next to the assertion
|
||||
* that every other mail *has* that button, so adding a third such mail is a decision rather
|
||||
* than an accident.
|
||||
*
|
||||
* @var list<EmailTextKey>
|
||||
*/
|
||||
/**
|
||||
* Stands in for APPLICATION_FORM_BOOKING_URL. Unlike the other placeholders this one is not
|
||||
* caller-supplied - it reaches the body from configuration, before the Markdown is parsed -
|
||||
* so the assertions below look for the URL itself rather than a "[name]" marker.
|
||||
*/
|
||||
private const BOOKING_URL = 'https://bookings.example.test/team-infocall';
|
||||
|
||||
/**
|
||||
* @var list<EmailTextKey>
|
||||
*/
|
||||
private const NO_PORTAL_BUTTON = [
|
||||
EmailTextKey::APPLICATION_FORM_RECEIVED,
|
||||
EmailTextKey::APPLICATION_FORM_FOLLOW_UP,
|
||||
];
|
||||
|
||||
/**
|
||||
* @dataProvider provideKeys
|
||||
*/
|
||||
@@ -43,15 +66,22 @@ class EmailTextMailRenderingTest extends KernelTestCase
|
||||
$repository = $this->createMock(EmailTextRepository::class);
|
||||
$repository->method('findByKey')->willReturn(null);
|
||||
|
||||
$text = (new EmailTextRenderer($catalog, $repository, $container->get(MailBodyRenderer::class)))
|
||||
->render($key, $placeholders)
|
||||
;
|
||||
$text = (new EmailTextRenderer(
|
||||
$catalog,
|
||||
$repository,
|
||||
$container->get(MailBodyRenderer::class),
|
||||
self::BOOKING_URL
|
||||
))->render($key, $placeholders);
|
||||
|
||||
$withoutButton = in_array($key, self::NO_PORTAL_BUTTON, true);
|
||||
|
||||
$email = $container->get(Mailer::class)->create(['text' => $text], [
|
||||
'from' => '[email protected]',
|
||||
'to' => '[email protected]',
|
||||
'subject' => $text->subject,
|
||||
'template' => 'email/generic.html.twig',
|
||||
'template' => $withoutButton
|
||||
? 'email/generic_no_button.html.twig'
|
||||
: 'email/generic.html.twig',
|
||||
'subject_parameters' => [],
|
||||
'attachments' => [],
|
||||
'transport' => null,
|
||||
@@ -62,15 +92,30 @@ class EmailTextMailRenderingTest extends KernelTestCase
|
||||
|
||||
$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);
|
||||
if (true === $withoutButton) {
|
||||
// The recipient filled in a form on a public website and has no account here, so a
|
||||
// link to a login they cannot pass would be worse than no link.
|
||||
$this->assertStringNotContainsString('Zum Portal', $html);
|
||||
} else {
|
||||
// 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) {
|
||||
if ('bookingUrl' === $name) {
|
||||
// Configured, not passed in, and substituted before parsing - so it has to have
|
||||
// become a real link target rather than a percent-encoded token.
|
||||
$this->assertStringContainsString('href="'.self::BOOKING_URL.'"', $html);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'['.$name.']',
|
||||
$html.$email->getSubject(),
|
||||
@@ -79,6 +124,68 @@ class EmailTextMailRenderingTest extends KernelTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The follow-up mail is the one piece of delivered wording that leans on Markdown escaping:
|
||||
* "Saisonmitarbeiter\*in" has a single asterisk, which CommonMark reads as emphasis unless it
|
||||
* is escaped. Getting that wrong does not fail anything - it silently italicises a stretch of
|
||||
* the mail - so it is pinned here.
|
||||
*/
|
||||
public function testTheFollowUpSurvivesItsMarkdown(): void
|
||||
{
|
||||
$html = $this->renderBody(EmailTextKey::APPLICATION_FORM_FOLLOW_UP);
|
||||
|
||||
$this->assertStringContainsString('Saisonmitarbeiter*in', $html);
|
||||
$this->assertStringNotContainsString('<em>in', $html);
|
||||
// The call dates are written as a list and the two parts as headings.
|
||||
$this->assertStringContainsString('<ul>', $html);
|
||||
$this->assertStringContainsString('Wochenteam', $html);
|
||||
$this->assertStringContainsString('Saisonpersonal', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unconfigured booking URL must stop the mail, not render <a href=""> and invite an
|
||||
* applicant to book a call they cannot reach. The endpoint turns this into a logged error and
|
||||
* still answers the submission; a reviewer sending by hand sees it.
|
||||
*/
|
||||
public function testAnUnconfiguredBookingUrlRefusesToRender(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = self::getContainer();
|
||||
$repository = $this->createMock(EmailTextRepository::class);
|
||||
$repository->method('findByKey')->willReturn(null);
|
||||
|
||||
$renderer = new EmailTextRenderer(
|
||||
$container->get(EmailTextCatalog::class),
|
||||
$repository,
|
||||
$container->get(MailBodyRenderer::class),
|
||||
''
|
||||
);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('APPLICATION_FORM_BOOKING_URL is not configured');
|
||||
|
||||
$renderer->render(EmailTextKey::APPLICATION_FORM_RECEIVED, []);
|
||||
}
|
||||
|
||||
private function renderBody(EmailTextKey $key): string
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = self::getContainer();
|
||||
$repository = $this->createMock(EmailTextRepository::class);
|
||||
$repository->method('findByKey')->willReturn(null);
|
||||
|
||||
$text = (new EmailTextRenderer(
|
||||
$container->get(EmailTextCatalog::class),
|
||||
$repository,
|
||||
$container->get(MailBodyRenderer::class),
|
||||
self::BOOKING_URL
|
||||
))->render($key, []);
|
||||
|
||||
return $text->bodyHtml;
|
||||
}
|
||||
|
||||
public static function provideKeys(): iterable
|
||||
{
|
||||
foreach (EmailTextKey::cases() as $key) {
|
||||
|
||||
Reference in new issue
Block a user