fix: accept the German W as a gender on the application endpoint
This commit is contained in:
1 parent
5141673bcd
commit
e460b18661
3 files changed
+107
-5
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user