client = static::createClient(server: ['HTTPS' => true]); $this->entityManager()->createQuery( sprintf('DELETE FROM %s', ApplicationFormSubmission::class) )->execute(); } /** * The endpoint promises JSON out, and on the error path that is not decoration: the TYPO3 * finisher logs the response body, and Symfony's error renderer would answer an HTML error page * here - one that names the offending field only in dev, inside an HTML comment, and not at all * in production. See ApiExceptionListener. */ public function testARejectionIsJsonNamingTheField(): void { $payload = $this->payload(); unset($payload['birthDate']); $this->post($payload); $response = $this->client->getResponse(); $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); $this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type')); $this->assertStringNotContainsString('getContent()); $body = $this->decode(); $this->assertSame('error', $body['status']); $this->assertSame( [['field' => 'birthDate', 'message' => 'birthDate must not be empty.', 'received' => 'missing']], $body['errors'] ); // One line for a caller that logs the message alone. $this->assertSame('birthDate must not be empty.', $body['message']); } /** * The shape of what arrived, which the framework never says: "This value should be of type * null|string." names the expectation and leaves the sender guessing. A form engine that hands a * date element straight to json_encode sends an object, and its keys are the whole diagnosis. * * Never the value itself - a rejected payload is still personal data. * * @dataProvider unusableBirthDateTypes */ public function testARejectionNamesTheShapeThatArrived(mixed $birthDate, string $received): void { $this->post(['birthDate' => $birthDate] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame( [['field' => 'birthDate', 'message' => 'This value should be of type null|string.', 'received' => $received]], $this->decode()['errors'] ); } /** * @return iterable */ public static function unusableBirthDateTypes(): iterable { yield 'a serialized date object' => [ ['date' => '1990-01-01 00:00:00.000000', 'timezone_type' => 3, 'timezone' => 'Europe/Berlin'], 'object with keys: date, timezone_type, timezone', ]; yield 'a list' => [['1990-01-01'], 'list of 1']; yield 'a number' => [19900101, 'int']; yield 'a boolean' => [true, 'bool']; } /** * The endpoint's errors are English (the only caller is a machine), but a constraint message this * application does not phrase itself is translated by the validator - and this application's * locale is German. The message is therefore rebuilt from its own template rather than read off * the violation. */ public function testAFrameworkMessageIsNotTranslated(): void { $this->post(['birthDate' => 19900101] + $this->payload()); $content = (string) $this->client->getResponse()->getContent(); $this->assertStringContainsString('This value should be of type null|string.', $content); $this->assertStringNotContainsString('Dieser Wert', $content); } public function testAnEmptyValueIsDistinguishedFromAnAbsentOne(): void { $this->post(['city' => ''] + $this->payload()); $this->assertSame('empty string', $this->decode()['errors'][0]['received']); } public function testEveryOffendingFieldIsNamed(): void { $payload = $this->payload(); unset($payload['street'], $payload['zipCode'], $payload['city']); $this->post($payload); $this->assertEqualsCanonicalizing(['street', 'zipCode', 'city'], $this->rejectedFields()); } /** * A malformed body has no field to name, so the resolver's own explanation is passed on rather * than swallowed - still JSON, still readable in a log. */ public function testABrokenBodyIsRejectedAsJson(): void { $this->client->request( 'POST', self::URL, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY], content: '{"form": "applicationform",' ); $body = $this->decode(); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame([], $body['errors']); $this->assertNotSame('', $body['message']); } /** * Not only the validation path: a wrong method comes from the router and used to leave as HTML * too. */ public function testAWrongMethodIsJsonToo(): 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()); $this->assertSame('error', $this->decode()['status']); } public function testAKeylessRequestIsRejected(): void { $this->post(['form' => 'applicationform'], key: null); $this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); $this->assertSame('error', $this->decode()['status']); } /** * A wrong key is answered by the firewall's entry point, which stops the exception before * ApiExceptionListener sees it: the body stays empty. Pinned because "empty" is the thing to * recognise in a caller's log - it is not the HTML error page a rejected payload used to get, * and it is not something this application can phrase without an entry point of its own. */ public function testAWrongKeyIsRejected(): void { $this->post(['form' => 'applicationform'], key: 'not-the-key'); $response = $this->client->getResponse(); $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); $this->assertStringNotContainsString('getContent()); } 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' => '1990-01-01', 'gender' => 'F', 'eu' => true, '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('1990-01-01', $submission->getBirthDate()); $this->assertSame('1990-01-01', $submission->getDateOfBirth()?->format('Y-m-d')); $this->assertSame('F', $submission->getGender()); $this->assertSame('anna@example.com', $submission->getEmail(), 'the address is normalized like everywhere else'); $this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']); } /** * Beyond the required nine everything stays optional: a form is configured without a mobile * number as often as with it, and the teamer fills those in themselves afterwards. An absent * optional key means the form has no such field - never an empty value that failed validation. */ public function testTheRequiredFieldsAloneAreEnough(): void { $this->post($this->payload()); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $submission = $this->lastSubmission(); $this->assertNull($submission->getPhone()); $this->assertNull($submission->getMobile()); $this->assertSame([], $submission->getDetails()); } /** * @dataProvider requiredFields */ public function testAPayloadMissingARequiredFieldIsRejected(string $field): void { $payload = $this->payload(); unset($payload[$field]); $this->post($payload); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); // The whole point of the rejection: the caller logs this body and has to see the field. $this->assertContains($field, $this->rejectedFields()); } /** * An empty string is the shape a form posts for a field the visitor left blank, and it is as * unusable as an absent key - except for eu, where "" is a lawful no and has its own case. * * @dataProvider requiredFields */ public function testAPayloadWithAnEmptyRequiredFieldIsRejected(string $field): void { if ('eu' === $field) { $this->markTestSkipped('An empty eu is a no, not a missing answer - see its own test'); } $this->post([$field => ''] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); } /** * @return iterable */ public static function requiredFields(): iterable { yield 'form' => ['form']; yield 'formLabel' => ['formLabel']; yield 'submittedAt' => ['submittedAt']; yield 'firstName' => ['firstName']; yield 'lastName' => ['lastName']; yield 'email' => ['email']; yield 'birthDate' => ['birthDate']; yield 'gender' => ['gender']; yield 'eu' => ['eu']; yield 'street' => ['street']; yield 'zipCode' => ['zipCode']; yield 'city' => ['city']; } /** * The one field whose format is nailed down: provisioning parses it as a date, so the day the * TYPO3 side switches a form back to a free-text birthday has to be a 400 here rather than a * failure in front of a reviewer weeks later. * * @dataProvider unusableBirthDates */ public function testAnUnusableBirthDateIsRejected(string $birthDate): void { $this->post(['birthDate' => $birthDate] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); } /** * @return iterable */ public static function unusableBirthDates(): iterable { yield 'the German spelling the forms used before the contract' => ['01.01.1990']; yield 'free text' => ['irgendwann 1990']; yield 'a day that does not exist' => ['1990-02-31']; yield 'a year alone' => ['1990']; } public function testAnUnknownGenderIsRejected(): void { $this->post(['gender' => 'weiblich'] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); } /** * D is passed through to BusPro as it is - what BusPro makes of it is unverified, and guessing * a replacement here would be worse than finding out. */ public function testEveryGenderTheApplicationKnowsIsAccepted(): void { foreach (['F', 'M', 'D'] as $gender) { $this->post(['gender' => $gender] + $this->payload()); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $this->assertSame($gender, $this->lastSubmission()->getGender()); } } /** * 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(['nationality' => 'deutsch'] + $this->payload()); $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(['details' => $details] + $this->payload()); $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(['details' => ['Warum ich' => $answer]] + $this->payload()); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']); } public function testAPayloadWithoutPageInformationIsAccepted(): void { $this->post(['details' => ['Nationalität' => 'deutsch']] + $this->payload()); $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(['pageUid' => '1421'] + $this->payload()); $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($this->payload(), 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 testAnUnparseableSubmissionTimeIsRejected(): void { $this->post(['submittedAt' => 'gestern'] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); } public function testAnInvalidEmailAddressIsRejected(): void { $this->post(['email' => 'not-an-address'] + $this->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); } public function testANestedDetailValueIsRejected(): void { $this->post(['details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]]] + $this->payload()); $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 = $this->payload(); $this->post($payload); $this->post($payload); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $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->payload()); $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->payload()); $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); $this->assertEmailCount(0); $this->assertNull($this->lastSubmission()->getFirstMailSentAt()); $this->assertFalse($this->lastSubmission()->isEu()); } /** * 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->payload()); $this->assertEmailCount(1); $this->assertTrue($this->lastSubmission()->isEu()); } public function testAnEmptyEuFlagIsNotAYes(): void { $this->post(['eu' => ''] + $this->payload()); $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->payload()); $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); $this->assertSame(0, $this->countSubmissions()); $this->assertEmailCount(0); } /** * A payload with nothing but the required six, which is the smallest thing the endpoint * accepts. Spread an override in front of it - ['gender' => 'X'] + $this->payload() - rather * than rebuilding it, so a field added to the contract reaches every case at once. * * @return array */ private function payload(): array { return [ 'form' => 'applicationform', 'formLabel' => 'Bewerbungsformular', 'submittedAt' => '2026-09-22T12:30:00+02:00', 'firstName' => 'Anna', 'lastName' => 'Muster', 'email' => 'anna@example.com', 'birthDate' => '1990-01-01', 'gender' => 'F', 'eu' => true, // The postal address, required as a whole: street and postcode are BusPro's own // mandatory fields for a new address. 'street' => 'Musterstraße 1', 'zipCode' => '10115', 'city' => 'Berlin', 'details' => [], ]; } /** * The fields the endpoint named as the reason for a rejection. * * @return list */ private function rejectedFields(): array { return array_values(array_filter(array_column($this->decode()['errors'], 'field'))); } /** * @return array */ private function decode(): array { $body = json_decode((string) $this->client->getResponse()->getContent(), true); $this->assertIsArray($body, 'The endpoint has to answer JSON, on the error path as well'); return $body; } /** * @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); } }