704 lines
27 KiB
PHP
704 lines
27 KiB
PHP
<?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:
|
|
* 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.
|
|
*
|
|
* The other half is the required nine of the contract: every payload carries lastName, firstName,
|
|
* birthDate, email, gender, eu, street, zipCode and city, and every one of them is a 400 when it is
|
|
* missing. That is
|
|
* deliberately harsh - a 400 costs a whole application - and each rejection case is pinned here
|
|
* one field at a time, because these are the fields a teamer cannot be provisioned without.
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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('<html', (string) $response->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.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\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<string, array{mixed, string}>
|
|
*/
|
|
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('<html', (string) $response->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' => '[email protected]',
|
|
'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('[email protected]', $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());
|
|
}
|
|
|
|
#[\PHPUnit\Framework\Attributes\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.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\DataProvider('nonBlankFields')]
|
|
public function testAPayloadWithAnEmptyRequiredFieldIsRejected(string $field): void
|
|
{
|
|
$this->post([$field => ''] + $this->payload());
|
|
|
|
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
|
$this->assertSame(0, $this->countSubmissions());
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string}>
|
|
*/
|
|
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 required fields that are NotBlank as well: all but eu, which is only NotNull because ""
|
|
* is a lawful no (testAnEmptyEuFlagIsNotAYes).
|
|
*
|
|
* @return iterable<string, array{string}>
|
|
*/
|
|
public static function nonBlankFields(): iterable
|
|
{
|
|
foreach (self::requiredFields() as $name => $arguments) {
|
|
if ('eu' !== $name) {
|
|
yield $name => $arguments;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\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<string, array{string}>
|
|
*/
|
|
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 German spelling of female. The forms are German and some of them post W; it is accepted
|
|
* and stored as F, so the row, the provisioned teamer and the payload all read the same
|
|
* whichever spelling arrived.
|
|
*/
|
|
public function testTheGermanWIsAcceptedAndStoredAsF(): void
|
|
{
|
|
$this->post(['gender' => 'W'] + $this->payload());
|
|
|
|
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
|
|
|
$submission = $this->lastSubmission();
|
|
|
|
$this->assertSame('F', $submission->getGender());
|
|
$this->assertSame('W', $submission->toPayload()['geschlecht']);
|
|
$this->assertSame('W', $submission->getPayload()['gender'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* Case is the sender's business, not a reason to lose an application: every spelling a form
|
|
* can post lands on the same stored value.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\DataProvider('genderSpellings')]
|
|
public function testAGenderIsReadWhateverItsCase(string $posted, string $stored): void
|
|
{
|
|
$this->post(['gender' => $posted] + $this->payload());
|
|
|
|
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
|
$this->assertSame($stored, $this->lastSubmission()->getGender());
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string, string}>
|
|
*/
|
|
public static function genderSpellings(): iterable
|
|
{
|
|
yield 'lowercase f' => ['f', 'F'];
|
|
yield 'lowercase w' => ['w', 'F'];
|
|
yield 'lowercase m' => ['m', 'M'];
|
|
yield 'lowercase d' => ['d', 'D'];
|
|
yield 'padded' => [' W ', 'F'];
|
|
}
|
|
|
|
/**
|
|
* 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. 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());
|
|
}
|
|
|
|
/**
|
|
* Dedupe 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<string, mixed>
|
|
*/
|
|
private function payload(): array
|
|
{
|
|
return [
|
|
'form' => 'applicationform',
|
|
'formLabel' => 'Bewerbungsformular',
|
|
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
|
'firstName' => 'Anna',
|
|
'lastName' => 'Muster',
|
|
'email' => '[email protected]',
|
|
'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<string>
|
|
*/
|
|
private function rejectedFields(): array
|
|
{
|
|
return array_values(array_filter(array_column($this->decode()['errors'], 'field')));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<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);
|
|
}
|
|
}
|