feat: accept form submissions from the websites over an API endpoint
This commit is contained in:
1 parent
f48e53fdd0
commit
30ad5e8488
17 files changed
+1350
-4
No files matched your search
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Repository\ApplicationFormSubmissionRepository;
|
||||
use App\Security\AccessToken\ApiKeyExtractor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The cases here are the ones the TYPO3 side actually produces
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): a payload of nothing but the basics, labels
|
||||
* with slashes and umlauts, a long textarea answer, a duplicate label, and a mapped key this
|
||||
* application does not know - which must not be a 400, or a later mapping change on the
|
||||
* website silently stops every submission of that form.
|
||||
*/
|
||||
class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
private const URL = '/api/application-forms';
|
||||
|
||||
private const KEY = 'test-application-form-api-key';
|
||||
|
||||
private KernelBrowser $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// access_control requires https for every path, so an http request is a 301 here
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
|
||||
$this->entityManager()->createQuery(
|
||||
sprintf('DELETE FROM %s', ApplicationFormSubmission::class)
|
||||
)->execute();
|
||||
}
|
||||
|
||||
public function testAKeylessRequestIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: null);
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: 'not-the-key');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAFullSubmissionIsStored(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'pageUid' => 1421,
|
||||
'pageUrl' => 'https://www.ep-reisen.de/jobs/bewerbung',
|
||||
'firstName' => 'Anna',
|
||||
'lastName' => 'Muster',
|
||||
'email' => '[email protected]',
|
||||
'phone' => '+49 30 1234567',
|
||||
'mobile' => '+49 170 1234567',
|
||||
'birthDate' => '01.01.1990',
|
||||
'street' => 'Musterstraße 1',
|
||||
'zipCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'details' => [
|
||||
'Nationalität' => 'deutsch',
|
||||
'Ausbildung/Studium' => 'Tourismusmanagement',
|
||||
'Bewerbung als' => ['Reiseleitung', 'Skilehrer'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$submission = $this->lastSubmission();
|
||||
|
||||
$this->assertSame('applicationform', $submission->getForm());
|
||||
$this->assertSame('Bewerbungsformular', $submission->getFormLabel());
|
||||
$this->assertSame('2026-09-22T12:30:00+02:00', $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM));
|
||||
$this->assertSame(1421, $submission->getPageUid());
|
||||
$this->assertSame('01.01.1990', $submission->getBirthDate());
|
||||
$this->assertSame('[email protected]', $submission->getEmail(), 'the address is normalized like everywhere else');
|
||||
$this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A form may map nothing but the basics, and an absent mapped key means the form has no such
|
||||
* field - never an empty value that failed validation.
|
||||
*/
|
||||
public function testTheBasicsAloneAreEnough(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'contactform',
|
||||
'formLabel' => 'Kontaktformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$submission = $this->lastSubmission();
|
||||
|
||||
$this->assertNull($submission->getFirstName());
|
||||
$this->assertNull($submission->getEmail());
|
||||
$this->assertSame([], $submission->getDetails());
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped vocabulary grows on the website side without a release here. An unknown key is
|
||||
* ignored, and kept in the raw payload so it can be mapped later without losing the answers
|
||||
* that arrived in the meantime.
|
||||
*/
|
||||
public function testAnUnknownMappedKeyIsIgnoredAndKept(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'nationality' => 'deutsch',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame('deutsch', $this->lastSubmission()->getPayload()['nationality']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A duplicate label is disambiguated by the identifier on the TYPO3 side, so both answers
|
||||
* survive as separate keys (§3.3). The order is MySQL's, not the form's - a json column
|
||||
* sorts an object's keys by length - so only the set of labels is pinned here.
|
||||
*/
|
||||
public function testEveryLabelSurvivesIncludingADisambiguatedDuplicate(): void
|
||||
{
|
||||
$details = [
|
||||
'Nationalität' => 'deutsch',
|
||||
'Führerschein' => 'ja',
|
||||
'Einsatz datum' => 'ab 20.12.2026',
|
||||
'Geburtsdatum' => '01.01.1990',
|
||||
'Geburtsdatum (text-10)' => '02.02.1992',
|
||||
];
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => $details,
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$stored = $this->lastSubmission()->getDetails();
|
||||
|
||||
$this->assertEqualsCanonicalizing(array_keys($details), array_keys($stored));
|
||||
$this->assertSame('01.01.1990', $stored['Geburtsdatum']);
|
||||
$this->assertSame('02.02.1992', $stored['Geburtsdatum (text-10)']);
|
||||
}
|
||||
|
||||
public function testALongAnswerIsStored(): void
|
||||
{
|
||||
$answer = str_repeat('ä', 3000);
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Warum ich' => $answer],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']);
|
||||
}
|
||||
|
||||
public function testAPayloadWithoutPageInformationIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Nationalität' => 'deutsch'],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertNull($this->lastSubmission()->getPageUid());
|
||||
$this->assertNull($this->lastSubmission()->getPageUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* pageUid arrives as a JSON number today. A numeric string is pinned here because the
|
||||
* distinction is invisible in a form editor and nobody would notice it becoming a 400.
|
||||
*/
|
||||
public function testANumericStringPageUidIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'pageUid' => '1421',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(1421, $this->lastSubmission()->getPageUid());
|
||||
}
|
||||
|
||||
public function testABodyWithoutAJsonContentTypeIsRejected(): void
|
||||
{
|
||||
$this->client->request(
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: json_encode([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
// Pinned because it is what the TYPO3 side has to be told: the resolver needs the
|
||||
// header to read the body as JSON at all, and answers 400 without it.
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testABrokenBodyIsRejected(): void
|
||||
{
|
||||
$this->client->request(
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: '{"form": "applicationform",'
|
||||
);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAMissingFormIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAnUnparseableSubmissionTimeIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => 'gestern',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAnInvalidEmailAddressIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => 'not-an-address',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testANestedDetailValueIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testTheEndpointOnlyAnswersPost(): void
|
||||
{
|
||||
$this->client->request('GET', self::URL, server: [
|
||||
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY,
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* §5.1 is not implemented yet: a double-clicked submit button stores two rows for now, and
|
||||
* this pins that it is a known state rather than an accident.
|
||||
*/
|
||||
public function testTheSamePayloadTwiceIsStoredTwiceForNow(): void
|
||||
{
|
||||
$payload = [
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => '[email protected]',
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
$this->post($payload);
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(2, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function post(array $payload, ?string $key = self::KEY): void
|
||||
{
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
|
||||
if (null !== $key) {
|
||||
$server['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER))] = $key;
|
||||
}
|
||||
|
||||
$this->client->request('POST', self::URL, server: $server, content: json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function lastSubmission(): ApplicationFormSubmission
|
||||
{
|
||||
$this->entityManager()->clear();
|
||||
|
||||
/** @var ApplicationFormSubmissionRepository $repository */
|
||||
$repository = $this->entityManager()->getRepository(ApplicationFormSubmission::class);
|
||||
|
||||
$submission = $repository->findOneBy([], ['id' => 'DESC']);
|
||||
|
||||
$this->assertInstanceOf(ApplicationFormSubmission::class, $submission);
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function countSubmissions(): int
|
||||
{
|
||||
return (int) $this->entityManager()
|
||||
->createQuery(sprintf('SELECT COUNT(s.id) FROM %s s', ApplicationFormSubmission::class))
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
private function entityManager(): EntityManagerInterface
|
||||
{
|
||||
return static::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user