From 9ac62a60216955975a8a177160061254d743818a Mon Sep 17 00:00:00 2001
From: James Griffin
Date: Wed, 29 Jul 2026 20:46:27 -0300
Subject: [PATCH] Collect a birth year instead of a full date of birth
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
CHANGELOG.md | 1 +
assets/css/frontend.css | 2 +-
docs/features/account-registration.md | 2 +-
docs/features/parent-guardian-accounts.md | 27 +++++-
src/Auth/RegistrationPage.php | 14 +--
src/BlockPreview.php | 4 +-
src/Guardian/FamilyPage.php | 6 +-
src/Guardian/GuardianService.php | 99 ++++++++++++++++-----
templates/admin/student-detail.php | 6 +-
templates/admin/students.php | 2 +-
templates/frontend/family-page.php | 14 +--
templates/frontend/register-page.php | 4 +-
tests/Unit/Auth/RegistrationPageTest.php | 20 ++---
tests/Unit/Guardian/FamilyPageTest.php | 14 +--
tests/Unit/Guardian/GuardianServiceTest.php | 81 +++++++++++++++--
15 files changed, 219 insertions(+), 77 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a596d9c..ef6e2f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ each change under the current top section as you work.
## [1.3.1]
### Changed
+- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
### Fixed
diff --git a/assets/css/frontend.css b/assets/css/frontend.css
index 78f1a1e..24b8cbe 100644
--- a/assets/css/frontend.css
+++ b/assets/css/frontend.css
@@ -492,7 +492,7 @@
font-weight: 600;
}
-.us-family-child-dob {
+.us-family-child-birth-year {
font-size: 0.9em;
opacity: 0.75;
}
diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md
index 4cea3b2..564f840 100644
--- a/docs/features/account-registration.md
+++ b/docs/features/account-registration.md
@@ -168,7 +168,7 @@ No-op when no registration page is set.
## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**,
-which reveals a repeatable child block (name, date of birth, and the
+which reveals a repeatable child block (name, birth year, and the
account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path
diff --git a/docs/features/parent-guardian-accounts.md b/docs/features/parent-guardian-accounts.md
index ed0cbe2..fc9cc05 100644
--- a/docs/features/parent-guardian-accounts.md
+++ b/docs/features/parent-guardian-accounts.md
@@ -67,11 +67,28 @@ requires migrating every existing row.
never be linked twice.
The table is a link table, not a child record: the child's **name** is their
-`display_name` on `wp_users`, and their date of birth is the `us_date_of_birth`
+`display_name` on `wp_users`, and their birth year is the `us_birth_year`
user meta. Keeping them on the user row means the admin student screens,
`get_users()` ordering, and every existing `student_id` lookup keep working with
no special-casing.
+### The legacy `us_date_of_birth` meta
+
+This feature originally collected a full date of birth in `us_date_of_birth`.
+Nothing writes that key any more. It is handled entirely inside
+`GuardianService`:
+
+- **Read** — `birthYear()` falls back to the year of the old date when
+ `us_birth_year` is absent, so a child added before the change still shows one
+ without a migration step.
+- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
+ including a save that clears the year. Without that the fallback would
+ resurrect the old date on the next read and the year could never be cleared.
+
+The upshot is a lazy migration: a child's full date survives until their record
+is next edited, then goes for good. There is no bulk purge — a site that wants
+the remaining old dates gone should delete the `us_date_of_birth` meta directly.
+
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
refuses to link a child that already has a guardian. The unique key and the
guardian-side lookups already support many-to-many, so adding a second guardian
@@ -130,7 +147,9 @@ least one child name.
Per child the form collects:
- **Name** (required)
-- **Date of birth** (optional, `us_date_of_birth` meta)
+- **Birth year** (optional, `us_birth_year` meta) — a four-digit year between
+ 1900 and the current year. Anything else is discarded rather than stored, so
+ a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian
@@ -173,12 +192,12 @@ child.
## Managing children
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
-list the children, add one, edit a name/date of birth, remove one.
+list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope
questions are asked here too, so a child added later carries the same
information as one added at signup.
-- **Edit** updates `display_name` and `us_date_of_birth`.
+- **Edit** updates `display_name` and `us_birth_year`.
- **Remove** unlinks the child and **deletes the child user**, but only when the
child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit
diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php
index 5f1e85a..dca08a2 100644
--- a/src/Auth/RegistrationPage.php
+++ b/src/Auth/RegistrationPage.php
@@ -477,11 +477,11 @@ class RegistrationPage {
/**
* The child blocks submitted with a guardian signup, as
- * `children[][name|dob|answers]`. Blocks with no name are dropped rather
+ * `children[][name|birth_year|answers]`. Blocks with no name are dropped rather
* than rejected — the form always renders one spare block for "add another",
* and an untouched spare is not a mistake the guardian needs telling about.
*
- * @return list}>
+ * @return list}>
*/
private function submittedChildren(): array {
// The submit nonce is verified by the caller before this runs.
@@ -508,9 +508,9 @@ class RegistrationPage {
}
$out[] = [
- 'name' => $name,
- 'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
- 'answers' => $answers,
+ 'name' => $name,
+ 'birth_year' => sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ),
+ 'answers' => $answers,
];
}
@@ -528,7 +528,7 @@ class RegistrationPage {
* re-register and children they never confirmed, so it is undone entirely and
* they simply try again.
*
- * @param list}> $children
+ * @param list}> $children
* @param list $questions
* @param list $policyForms
*/
@@ -536,7 +536,7 @@ class RegistrationPage {
$created = [];
foreach ( $children as $child ) {
- $childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] );
+ $childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
if ( $childId instanceof \WP_Error ) {
foreach ( $created as $id ) {
diff --git a/src/BlockPreview.php b/src/BlockPreview.php
index 2457500..35c7482 100644
--- a/src/BlockPreview.php
+++ b/src/BlockPreview.php
@@ -187,11 +187,11 @@ class BlockPreview {
$add = sprintf(
'
%s
'
- . ''
+ . ''
. '',
esc_html__( 'Add a student', 'unsupervised-schedular' ),
esc_html__( 'Name', 'unsupervised-schedular' ),
- esc_html__( 'Date of birth', 'unsupervised-schedular' ),
+ esc_html__( 'Birth year', 'unsupervised-schedular' ),
esc_html__( 'Add student', 'unsupervised-schedular' )
);
diff --git a/src/Guardian/FamilyPage.php b/src/Guardian/FamilyPage.php
index 9805662..a5da4f0 100644
--- a/src/Guardian/FamilyPage.php
+++ b/src/Guardian/FamilyPage.php
@@ -119,7 +119,7 @@ class FamilyPage {
*/
private function handleAdd( int $guardianId ): string|\WP_Error {
$name = $this->postString( 'child_name' );
- $dateOfBirth = $this->postString( 'child_dob' );
+ $birthYear = $this->postString( 'child_birth_year' );
$relationship = $this->postString( 'child_relationship' );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -130,7 +130,7 @@ class FamilyPage {
return $missing;
}
- $childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
+ $childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
if ( $childId instanceof \WP_Error ) {
return $childId;
}
@@ -144,7 +144,7 @@ class FamilyPage {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
- $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
+ $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
return $error ?? self::RESULT_UPDATED;
}
diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php
index 3ff8282..53fd04c 100644
--- a/src/Guardian/GuardianService.php
+++ b/src/Guardian/GuardianService.php
@@ -23,9 +23,24 @@ class GuardianService {
*/
public const META_CHILD = 'us_child';
- /** A child's date of birth (`Y-m-d`), collected at signup and editable after. */
+ /** A child's birth year (`YYYY`), collected at signup and editable after. */
+ public const META_BIRTH_YEAR = 'us_birth_year';
+
+ /**
+ * The full date of birth this feature used to collect. Nothing writes it any
+ * more: it is read once, to derive a birth year for a child who predates the
+ * change, and cleared the moment that child's record is next saved. Kept
+ * public so a site that wants to purge the old dates outright can find them.
+ */
public const META_DOB = 'us_date_of_birth';
+ /**
+ * The earliest birth year the form will accept. Old enough for any student a
+ * studio will ever enrol, and late enough to reject a typo like `19` or `190`
+ * that would otherwise be stored as a plausible-looking year.
+ */
+ private const MIN_BIRTH_YEAR = 1900;
+
/**
* Domain used for a child's placeholder login address. `.invalid` is reserved
* by RFC 2606 and can never resolve, so a child's address is guaranteed
@@ -48,7 +63,7 @@ class GuardianService {
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
* refuses the insert.
*/
- public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
+ public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
@@ -73,7 +88,7 @@ class GuardianService {
$userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' );
- $this->setDateOfBirth( $userId, $dateOfBirth );
+ $this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert(
new GuardianLink(
@@ -96,13 +111,13 @@ class GuardianService {
}
/**
- * Rename a child and update their date of birth. Refuses a student the caller
+ * Rename a child and update their birth year. Refuses a student the caller
* is not the guardian of, so the family screen cannot be turned into an
* arbitrary user editor by posting someone else's id.
*
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/
- public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error {
+ public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
@@ -124,7 +139,7 @@ class GuardianService {
return $result;
}
- $this->setDateOfBirth( $studentId, $dateOfBirth );
+ $this->setBirthYear( $studentId, $birthYear );
return null;
}
@@ -235,7 +250,7 @@ class GuardianService {
* A guardian's children, in link order, with the details the family and admin
* screens display.
*
- * @return list
+ * @return list
*/
public function children( int $guardianId ): array {
$out = [];
@@ -244,10 +259,10 @@ class GuardianService {
$user = get_userdata( $link->studentId );
$out[] = [
- 'id' => $link->studentId,
- 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
- 'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
- 'relationship' => $link->relationship,
+ 'id' => $link->studentId,
+ 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
+ 'birth_year' => $this->birthYear( $link->studentId ),
+ 'relationship' => $link->relationship,
];
}
@@ -327,24 +342,64 @@ class GuardianService {
}
/**
- * Store a child's date of birth, or clear it when blank or unparseable. Kept
- * as `Y-m-d` so it sorts and displays consistently wherever it is read.
+ * Store a child's birth year, or clear it when blank or out of range.
+ *
+ * Either way the legacy full date of birth goes with it. That is what makes
+ * the read fallback in {@see birthYear()} safe: without it, clearing the year
+ * on a child who predates this change would leave the old date behind for the
+ * fallback to resurrect on the very next read.
*/
- private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
- $dateOfBirth = trim( $dateOfBirth );
+ private function setBirthYear( int $userId, string $birthYear ): void {
+ delete_user_meta( $userId, self::META_DOB );
- if ( '' === $dateOfBirth ) {
- delete_user_meta( $userId, self::META_DOB );
+ $year = $this->normaliseBirthYear( $birthYear );
+
+ if ( 0 === $year ) {
+ delete_user_meta( $userId, self::META_BIRTH_YEAR );
return;
}
- $parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
- if ( false === $parsed ) {
- delete_user_meta( $userId, self::META_DOB );
- return;
+ update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
+ }
+
+ /**
+ * A submitted birth year as an integer, or 0 when it is blank, not a number,
+ * or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
+ * every time, so it is refused rather than stored.
+ */
+ private function normaliseBirthYear( string $birthYear ): int {
+ $birthYear = trim( $birthYear );
+
+ if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
+ return 0;
}
- update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
+ $year = (int) $birthYear;
+
+ if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
+ return 0;
+ }
+
+ return $year;
+ }
+
+ /**
+ * A child's birth year, or an empty string when none is recorded.
+ *
+ * Falls back to the year of the full date of birth this feature used to
+ * collect, so a child added before the change still shows one. The fallback
+ * is read-only and one-way: {@see setBirthYear()} drops the old date as soon
+ * as the record is saved again.
+ */
+ private function birthYear( int $userId ): string {
+ $year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
+ if ( '' !== $year ) {
+ return $year;
+ }
+
+ $legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
+
+ return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
}
/**
diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php
index 94b799e..dd21b4e 100644
--- a/templates/admin/student-detail.php
+++ b/templates/admin/student-detail.php
@@ -18,7 +18,7 @@ if (! defined('ABSPATH')) {
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
* @var string $creditCurrency
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
- * @var list $children Children this student books for.
+ * @var list $children Children this student books for.
* @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian.
* @var string $pageSlug
* @var string $backUrl
@@ -131,8 +131,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
-
-
+
+
diff --git a/templates/admin/students.php b/templates/admin/students.php
index b2ddd81..b7d662c 100644
--- a/templates/admin/students.php
+++ b/templates/admin/students.php
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
}
/**
- * @var list}> $students
+ * @var list}> $students
* @var string $pageSlug
*/
diff --git a/templates/frontend/family-page.php b/templates/frontend/family-page.php
index 466ed63..6269d30 100644
--- a/templates/frontend/family-page.php
+++ b/templates/frontend/family-page.php
@@ -8,7 +8,7 @@ if (! defined('ABSPATH')) {
}
/**
- * @var list $children
+ * @var list $children
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
* @var string $error Validation error from the last submission, if any.
* @var string $notice Confirmation of a completed add/edit/remove, if any.
@@ -42,8 +42,8 @@ if (! defined('ABSPATH')) {
-
-
+
+
@@ -52,8 +52,8 @@ if (! defined('ABSPATH')) {
-
-
+
+
@@ -80,8 +80,8 @@ if (! defined('ABSPATH')) {
-
-
+
+
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php
index 6a756e2..0483fb2 100644
--- a/templates/frontend/register-page.php
+++ b/templates/frontend/register-page.php
@@ -90,8 +90,8 @@ if (! defined('ABSPATH')) {
-
-
+
+
'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);
diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php
index 3730484..210018a 100644
--- a/tests/Unit/Guardian/FamilyPageTest.php
+++ b/tests/Unit/Guardian/FamilyPageTest.php
@@ -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();
diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php
index 805aaae..8b6c880 100644
--- a/tests/Unit/Guardian/GuardianServiceTest.php
+++ b/tests/Unit/Guardian/GuardianServiceTest.php
@@ -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 */
+ 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