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' => 'Anna@Example.com', '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('anna@example.com', $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' => 'anna@example.com', 'details' => [], ]; $this->post($payload); $this->post($payload); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $this->assertSame(2, $this->countSubmissions()); } /** * @param array $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); } }