fix: accept the German W as a gender on the application endpoint

This commit is contained in:
fromme committed 2026-09-26 10:25:48 +02:00
1 parent 5141673bcd
commit e460b18661
3 files changed
+107 -5

No files matched your search

+4 -3
View File
@@ -208,8 +208,9 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
/**
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
* parseable and gender is one of F/M/D by then, which is why the constructor can insist on
* both.
* parseable and gender is one of F/M/D by then (the payload may say W for female;
* ApplicationFormSubmissionDto::getGender() has folded it into F), which is why the
* constructor can insist on both.
*
* The raw payload is passed in separately because the DTO no longer has it: the resolver
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
@@ -220,7 +221,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
*/
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
{
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt(), (string) $dto->gender);
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt(), (string) $dto->getGender());
return $submission
->setPageUid($dto->getPageUid())
+59 -2
View File
@@ -39,10 +39,24 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
class ApplicationFormSubmissionDto
{
/**
* The stored vocabulary. W is not in here: it is accepted on the wire and folded into F by
* getGender(), so nothing downstream ever sees two spellings of the same gender.
*
* @var list<string>
*/
public const GENDERS = ['F', 'M', 'D'];
/**
* What a payload may say, before case is folded. The TYPO3 forms are German and some of them
* post the German W for female; refusing it - or refusing a lowercase w - would cost a whole
* application over a spelling. Checked by validateGender() rather than Assert\Choice, because
* Choice compares case-sensitively. Read the gender back through getGender(), never off the
* property.
*
* @var list<string>
*/
public const ACCEPTED_GENDERS = ['F', 'W', 'M', 'D'];
public const MAX_DETAIL_LABEL_LENGTH = 255;
public const MAX_DETAIL_VALUE_LENGTH = 65535;
@@ -86,9 +100,11 @@ class ApplicationFormSubmissionDto
#[Assert\Date(message: 'birthDate must be a date in the format YYYY-MM-DD.')]
public readonly ?string $birthDate = null,
// The application's own vocabulary, not BusPro's: ApplicationFormSubmission::toPayload()
// maps F to W on the way out, exactly as Teamer::toPayload() does.
// maps F to W on the way out, exactly as Teamer::toPayload() does. W is accepted on the
// way in as the German spelling of F, in either case, and normalised by getGender() - the
// storage stays F/M/D, and the W that reaches BusPro is the one toPayload() puts there.
// Validated by validateGender(), not Assert\Choice.
#[Assert\NotBlank(message: 'gender must not be empty.')]
#[Assert\Choice(choices: self::GENDERS, message: 'gender must be one of F, M or D.')]
public readonly ?string $gender = null,
// The whole postal address, required together. Street and postcode are BusPro's own
// Pflichtfelder for a new address (errors 891 and 890), so a submission without them is one
@@ -180,6 +196,27 @@ class ApplicationFormSubmissionDto
}
}
/**
* The same reasoning as validateEu(): getGender() folds case and the German W away, and what
* survives that must be one of the three the application stores. Anything else is a 400 with
* the vocabulary named, rather than a row nothing downstream can read.
*/
#[Assert\Callback]
public function validateGender(ExecutionContextInterface $executionContext): void
{
if (null === $this->gender || '' === $this->gender) {
return;
}
if (!in_array($this->getGender(), self::GENDERS, true)) {
$executionContext
->buildViolation('gender must be one of F, W, M or D.')
->atPath('gender')
->addViolation()
;
}
}
#[Assert\Callback]
public function validateDetails(ExecutionContextInterface $executionContext): void
{
@@ -240,6 +277,26 @@ class ApplicationFormSubmissionDto
};
}
/**
* The gender as it is stored: upper-cased, with the German W for female folded into F. A form
* posts "w" as readily as "W" and neither spelling is worth an application, so case is folded
* here rather than demanded of the sender - the same reasoning as isEu() and getPageUid().
*
* Called by validateGender() as well, so this runs on unvalidated input: it returns whatever
* the payload said, upper-cased, and it is validateGender() that decides whether that is one
* of GENDERS. Everything after validation sees only F, M, D or null.
*/
public function getGender(): ?string
{
if (null === $this->gender) {
return null;
}
$gender = strtoupper(trim($this->gender));
return 'W' === $gender ? 'F' : $gender;
}
public function getPageUid(): ?int
{
if (null === $this->pageUid) {
@@ -359,6 +359,50 @@ class ApplicationFormControllerTest extends WebTestCase
}
}
/**
* 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.
*
* @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