diff --git a/src/Entity/ApplicationFormSubmission.php b/src/Entity/ApplicationFormSubmission.php index 3975c13..63968c7 100644 --- a/src/Entity/ApplicationFormSubmission.php +++ b/src/Entity/ApplicationFormSubmission.php @@ -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()) diff --git a/src/Model/ApplicationFormSubmissionDto.php b/src/Model/ApplicationFormSubmissionDto.php index b6163bc..459dd67 100644 --- a/src/Model/ApplicationFormSubmissionDto.php +++ b/src/Model/ApplicationFormSubmissionDto.php @@ -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 */ 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 + */ + 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) { diff --git a/tests/Controller/Api/ApplicationFormControllerTest.php b/tests/Controller/Api/ApplicationFormControllerTest.php index 17c83d8..7f94011 100644 --- a/tests/Controller/Api/ApplicationFormControllerTest.php +++ b/tests/Controller/Api/ApplicationFormControllerTest.php @@ -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 + */ + 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