feat: answer received form submissions by mail

This commit is contained in:
fromme committed 2026-09-24 16:35:31 +02:00
1 parent 19364df2db
commit aa728ddc99
22 files changed
+974 -9

No files matched your search

@@ -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
*/