Collect a birth year instead of a full date of birth
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Signup and the profile page now ask for a four-digit year between 1900 and the current year. Anything else — a short year, a full date, a year in the future — is discarded rather than stored, so a typo cannot leave a nonsense age on the record. The year lives in a new us_birth_year user meta rather than reusing us_date_of_birth, which would have left one key holding two formats. The old key is not migrated in bulk. Instead GuardianService handles it in two halves: birthYear() falls back to the year of the old date when the new key is absent, so a student added before this change still shows one, and setBirthYear() deletes the old date on every save. That deletion is what makes the fallback safe rather than merely tidy. Without it, clearing the birth year on a student who predates the change would leave the old date behind for the fallback to read straight back, and the year could never be cleared at all. Stored in user meta, so no Schema.php change and no USC_VERSION bump. Closes #147 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -618,10 +618,10 @@ class RegistrationPageTest extends TestCase
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
|
||||
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => 'Violin']],
|
||||
// An untouched spare block is dropped, not rejected.
|
||||
['name' => ' ', 'dob' => '', 'answers' => []],
|
||||
['name' => ' ', 'birth_year' => '', 'answers' => []],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -633,7 +633,7 @@ class RegistrationPageTest extends TestCase
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
|
||||
|
||||
$recorded = [];
|
||||
@@ -658,7 +658,7 @@ class RegistrationPageTest extends TestCase
|
||||
'password' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [['name' => '', 'dob' => '', 'answers' => []]],
|
||||
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
|
||||
];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
@@ -681,8 +681,8 @@ class RegistrationPageTest extends TestCase
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']],
|
||||
['name' => 'Ada', 'birth_year' => '', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => ' ']],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -708,8 +708,8 @@ class RegistrationPageTest extends TestCase
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '', 'answers' => []],
|
||||
['name' => 'Alan', 'dob' => '', 'answers' => []],
|
||||
['name' => 'Ada', 'birth_year' => '', 'answers' => []],
|
||||
['name' => 'Alan', 'birth_year' => '', 'answers' => []],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -745,7 +745,7 @@ class RegistrationPageTest extends TestCase
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'accept' => [3],
|
||||
'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '', 'answers' => []]],
|
||||
];
|
||||
|
||||
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
|
||||
|
||||
@@ -38,6 +38,8 @@ class FamilyPageTest extends TestCase
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('get_permalink')->justReturn('https://studio.test/family/');
|
||||
// The birth-year input caps itself at the current year.
|
||||
Functions\when('current_time')->justReturn('2026');
|
||||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
|
||||
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
|
||||
@@ -92,14 +94,14 @@ class FamilyPageTest extends TestCase
|
||||
public function testRenderListsTheGuardiansChildren(): void
|
||||
{
|
||||
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
||||
['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'],
|
||||
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
|
||||
]);
|
||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertStringContainsString('Ada', $html);
|
||||
self::assertStringContainsString('2015-04-02', $html);
|
||||
self::assertStringContainsString('2015', $html);
|
||||
self::assertStringContainsString('Add a student', $html);
|
||||
}
|
||||
|
||||
@@ -108,13 +110,13 @@ class FamilyPageTest extends TestCase
|
||||
$_POST = [
|
||||
'us_family_action' => 'add',
|
||||
'child_name' => 'Ada',
|
||||
'child_dob' => '2015-04-02',
|
||||
'child_birth_year' => '2015',
|
||||
'child_relationship' => 'Parent',
|
||||
'us_answers' => [7 => 'Piano'],
|
||||
];
|
||||
|
||||
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
|
||||
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015-04-02', 'Parent')->andReturn(42);
|
||||
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
|
||||
|
||||
$this->answers->shouldReceive('insert')
|
||||
->once()
|
||||
@@ -179,10 +181,10 @@ class FamilyPageTest extends TestCase
|
||||
'us_family_action' => 'edit',
|
||||
'child_id' => '42',
|
||||
'child_name' => 'Ada L',
|
||||
'child_dob' => '2015-04-02',
|
||||
'child_birth_year' => '2015',
|
||||
];
|
||||
|
||||
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null);
|
||||
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
|
||||
|
||||
$captured = null;
|
||||
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||
|
||||
@@ -52,6 +52,8 @@ class GuardianServiceTest extends TestCase
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// The birth-year range is validated against "this year", so pin it.
|
||||
Functions\when('current_time')->justReturn('2026');
|
||||
Functions\when('wp_generate_password')->justReturn('abc123def456');
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||
@@ -84,14 +86,14 @@ class GuardianServiceTest extends TestCase
|
||||
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
|
||||
->andReturn(7);
|
||||
|
||||
$result = $this->service->createChild(5, ' Ada ', '2015-04-02', 'Parent');
|
||||
$result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
|
||||
|
||||
self::assertSame(42, $result);
|
||||
self::assertSame('Ada', $captured['display_name']);
|
||||
// The address is on the reserved .invalid TLD, so it can never receive mail.
|
||||
self::assertStringEndsWith('@child.invalid', $captured['user_email']);
|
||||
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
|
||||
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||
}
|
||||
|
||||
public function testCreateChildRejectsABlankName(): void
|
||||
@@ -117,14 +119,30 @@ class GuardianServiceTest extends TestCase
|
||||
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada'));
|
||||
}
|
||||
|
||||
public function testCreateChildClearsAnUnparseableDateOfBirth(): void
|
||||
/**
|
||||
* @dataProvider unusableBirthYears
|
||||
*/
|
||||
public function testCreateChildClearsAnUnusableBirthYear(string $submitted): void
|
||||
{
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
||||
|
||||
$this->service->createChild(5, 'Ada', 'not-a-date');
|
||||
$this->service->createChild(5, 'Ada', $submitted);
|
||||
|
||||
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
||||
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function unusableBirthYears(): array
|
||||
{
|
||||
return [
|
||||
'not a number' => ['not-a-year'],
|
||||
'a full date' => ['2015-04-02'],
|
||||
'too few digits' => ['15'],
|
||||
'too many digits' => ['20155'],
|
||||
'before 1900' => ['1899'],
|
||||
'later than today' => ['2027'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testCanActForSelfAndOwnChildOnly(): void
|
||||
@@ -187,6 +205,53 @@ class GuardianServiceTest extends TestCase
|
||||
self::assertSame([false, false, true], array_column($students, 'is_self'));
|
||||
}
|
||||
|
||||
public function testChildrenReportsTheStoredBirthYear(): void
|
||||
{
|
||||
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
|
||||
|
||||
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||
|
||||
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A child added before this feature switched to a year has only the old full
|
||||
* date on record, and must still show a birth year.
|
||||
*/
|
||||
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
|
||||
{
|
||||
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
||||
|
||||
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||
|
||||
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving a child drops the legacy full date. Without that, clearing the birth
|
||||
* year on a child who predates the change would leave the old date behind for
|
||||
* the fallback above to resurrect on the next read.
|
||||
*/
|
||||
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
|
||||
{
|
||||
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
||||
|
||||
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||
Functions\when('wp_update_user')->justReturn(42);
|
||||
|
||||
self::assertNull($this->service->updateChild(5, 42, 'Ada L', ''));
|
||||
|
||||
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
||||
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
|
||||
|
||||
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||
|
||||
self::assertSame('', $this->service->children(5)[0]['birth_year']);
|
||||
}
|
||||
|
||||
public function testBookableStudentsIsJustTheUserWithoutChildren(): void
|
||||
{
|
||||
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
|
||||
@@ -228,7 +293,7 @@ class GuardianServiceTest extends TestCase
|
||||
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
|
||||
}
|
||||
|
||||
public function testUpdateChildRenamesAndStoresTheDateOfBirth(): void
|
||||
public function testUpdateChildRenamesAndStoresTheBirthYear(): void
|
||||
{
|
||||
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||
Functions\expect('wp_update_user')
|
||||
@@ -236,8 +301,8 @@ class GuardianServiceTest extends TestCase
|
||||
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
|
||||
->andReturn(42);
|
||||
|
||||
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02'));
|
||||
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
||||
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
|
||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||
}
|
||||
|
||||
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
||||
|
||||
Reference in New Issue
Block a user