Require a name and birth year for every student
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped

Both fields are marked in their labels the same way a required registration
question is, and enforced on the server whichever form they arrive from:
GuardianService::createChild() and updateChild() now refuse a blank name or
an unusable birth year, and the signup form checks the same rule up front,
before it creates a single user, so a bad block never leaves a
half-registered family behind. normaliseBirthYear() became public and static
so both paths share one definition of what a usable year is.

The signup form cannot lean on the browser here. Its child blocks are hidden
until the parent/guardian box is ticked, and a `required` field inside a
hidden container makes the whole form unsubmittable with no control the user
can reach to fix — the same trap the guardian's own question panel already
sidesteps by disabling rather than hiding. So register.js puts `required` on
and takes it off along with the block itself, and the server is what makes
the rule hold with JavaScript off. The profile screen has no such problem:
its forms are always visible, so the attribute is static there.

One behaviour change beyond the requirement: a child block with anything
typed into it is now reported back instead of dropped. Previously any block
without a name was silently discarded, which would now mean losing a birth
year the guardian had filled in. A wholly untouched spare block — the one
the form always renders for "add another" — is still ignored.

Verified the required-toggling in a headless browser: unticked submits,
ticked blocks an empty block, a cloned block inherits the requirement, and
re-unticking leaves nothing behind to block a non-guardian signup.

Closes #148

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-29 21:00:45 -03:00
co-authored by Claude Opus 5
parent 2878beb221
commit 1d2f95d388
10 changed files with 215 additions and 52 deletions
+1
View File
@@ -14,6 +14,7 @@ each change under the current top section as you work.
## [1.3.1] ## [1.3.1]
### Changed ### Changed
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
- 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. - 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. - 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.
+14
View File
@@ -111,6 +111,16 @@
function sync() { function sync() {
children.hidden = !toggle.checked; children.hidden = !toggle.checked;
// Each student's name and birth year are required, but only once the
// block is in play: a `required` field inside a hidden container makes
// the form unsubmittable with no way to reach the offending control, so
// the attribute goes on and comes off with the block itself. The server
// enforces the same rule either way.
var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked;
}
if (!steps) { if (!steps) {
return; return;
} }
@@ -141,6 +151,10 @@
nextIndex += 1; nextIndex += 1;
children.insertBefore(clone, addButton.parentNode); children.insertBefore(clone, addButton.parentNode);
// The clone carries the data attribute but not necessarily the
// current required state, so settle it the same way as the rest.
sync();
}); });
} }
} }
+22 -6
View File
@@ -147,20 +147,36 @@ least one child name.
Per child the form collects: Per child the form collects:
- **Name** (required) - **Name** (required)
- **Birth year** (optional, `us_birth_year` meta) — a four-digit year between - **Birth year** (required, `us_birth_year` meta) — a four-digit year between
1900 and the current year. Anything else is discarded rather than stored, so 1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
a typo cannot leave a nonsense age on the record. definition of what counts, shared by the signup form's up-front validation and
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
than quietly discarded and a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`, - **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in `SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian practice they describe the student (instrument, level, school). The guardian
answers them on the child's behalf; the answer row's `student_id` is the child. answers them on the child's behalf; the answer row's `student_id` is the child.
Name and birth year are marked required in the labels the same way a required
question is, but the signup form **cannot** lean on the browser to enforce them:
the child blocks are hidden until the parent/guardian box is ticked, and a
`required` field inside a hidden container makes the form unsubmittable with no
control the user can reach to fix. `register.js` therefore puts `required` on
and takes it off along with the block itself (`[data-us-child-required]`), and
the server checks regardless — which is what makes the rule hold with
JavaScript off. The profile screen has no such problem: its forms are always
visible, so the attribute is static there.
Order of operations in `RegistrationPage::handleSubmit()`: Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the guardian's own fields (email, password, policies). 1. Validate the guardian's own fields (email, password, policies).
2. Validate **every** child block — a missing child name or a missing required 2. Validate **every** child block — a missing name, a missing or unusable birth
per-child answer fails the whole submission **before** any user is created, so year, or a missing required per-child answer fails the whole submission
a half-registered family is never left behind. **before** any user is created, so a half-registered family is never left
behind. An **entirely empty** block is dropped instead, because the form
always renders one spare for "add another"; a block with anything at all
typed into it is kept and reported on, rather than silently discarding what
the guardian entered.
3. Create the guardian user. 3. Create the guardian user.
4. For each child: create the accountless user, link it, record its answers, and 4. For each child: create the accountless user, link it, record its answers, and
record the signup policy acceptances **against the child** with record the signup policy acceptances **against the child** with
+28 -8
View File
@@ -289,6 +289,20 @@ class RegistrationPage {
return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' ); return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
} }
// Name and birth year are required per student, and are checked here for
// the same reason the questions below are: the child blocks are hidden
// until the guardian box is ticked, so the browser cannot be asked to
// enforce them without blocking a signup that has no children at all.
foreach ( $children as $child ) {
if ( '' === $child['name'] ) {
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
}
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
return esc_html( GuardianService::birthYearError() );
}
}
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) { foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
foreach ( $accountQuestions as $question ) { foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) { if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
@@ -477,9 +491,13 @@ class RegistrationPage {
/** /**
* The child blocks submitted with a guardian signup, as * The child blocks submitted with a guardian signup, as
* `children[<n>][name|birth_year|answers]`. Blocks with no name are dropped rather * `children[<n>][name|birth_year|answers]`.
* 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. * An **entirely empty** block is dropped rather than rejected — the form always
* renders one spare for "add another", and an untouched spare is not a mistake
* the guardian needs telling about. A block with anything at all filled in is
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
* year rather than silently discarding what they typed.
* *
* @return list<array{name: string, birth_year: string, answers: array<int, string>}> * @return list<array{name: string, birth_year: string, answers: array<int, string>}>
*/ */
@@ -497,19 +515,21 @@ class RegistrationPage {
continue; continue;
} }
$name = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ); $name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
if ( '' === trim( $name ) ) { $birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
continue;
}
$answers = []; $answers = [];
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) { foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); $answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
} }
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
continue;
}
$out[] = [ $out[] = [
'name' => $name, 'name' => $name,
'birth_year' => sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ), 'birth_year' => $birthYear,
'answers' => $answers, 'answers' => $answers,
]; ];
} }
+8 -2
View File
@@ -15,6 +15,12 @@ namespace Unsupervised\Schedular;
*/ */
class BlockPreview { class BlockPreview {
/**
* The marker a required field's label carries, matching the one
* {@see Registration\QuestionField::render()} puts on a required question.
*/
private const REQUIRED_MARK = ' <span class="us-required" aria-hidden="true">*</span>';
/** /**
* Sample booking page. * Sample booking page.
* *
@@ -186,8 +192,8 @@ class BlockPreview {
} }
$add = sprintf( $add = sprintf(
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>' '<h4>%s</h4><p><label for="us-child-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-child-name"></p>'
. '<p><label for="us-child-birth-year">%s</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>' . '<p><label for="us-child-birth-year">%s' . self::REQUIRED_MARK . '</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>', . '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Add a student', 'unsupervised-schedular' ), esc_html__( 'Add a student', 'unsupervised-schedular' ),
esc_html__( 'Name', 'unsupervised-schedular' ), esc_html__( 'Name', 'unsupervised-schedular' ),
+28 -4
View File
@@ -60,8 +60,8 @@ class GuardianService {
* is random and discarded — it is never stored anywhere readable, emailed, or * is random and discarded — it is never stored anywhere readable, emailed, or
* shown — so the account cannot be signed into even if the gate were removed. * shown — so the account cannot be signed into even if the gate were removed.
* *
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress * Returns the new user ID, or a `WP_Error` when the name is blank, the birth
* refuses the insert. * year is missing or unusable, or WordPress refuses the insert.
*/ */
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error { public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name ); $name = trim( $name );
@@ -69,6 +69,10 @@ class GuardianService {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
} }
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$email = $this->childEmail(); $email = $this->childEmail();
$userId = wp_insert_user( $userId = wp_insert_user(
[ [
@@ -127,6 +131,10 @@ class GuardianService {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
} }
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$result = wp_update_user( $result = wp_update_user(
[ [
'ID' => $studentId, 'ID' => $studentId,
@@ -352,7 +360,7 @@ class GuardianService {
private function setBirthYear( int $userId, string $birthYear ): void { private function setBirthYear( int $userId, string $birthYear ): void {
delete_user_meta( $userId, self::META_DOB ); delete_user_meta( $userId, self::META_DOB );
$year = $this->normaliseBirthYear( $birthYear ); $year = self::normaliseBirthYear( $birthYear );
if ( 0 === $year ) { if ( 0 === $year ) {
delete_user_meta( $userId, self::META_BIRTH_YEAR ); delete_user_meta( $userId, self::META_BIRTH_YEAR );
@@ -366,8 +374,11 @@ class GuardianService {
* A submitted birth year as an integer, or 0 when it is blank, not a number, * 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 * 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. * every time, so it is refused rather than stored.
*
* Public and static so the signup form can reject a bad year up front, before
* it creates any users, without a second copy of the rule to keep in step.
*/ */
private function normaliseBirthYear( string $birthYear ): int { public static function normaliseBirthYear( string $birthYear ): int {
$birthYear = trim( $birthYear ); $birthYear = trim( $birthYear );
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) { if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
@@ -383,6 +394,19 @@ class GuardianService {
return $year; return $year;
} }
/**
* The message shown when a birth year is missing or unusable. One phrasing,
* shared by the signup form and the profile screen, so a guardian is told the
* same thing whichever way they got there.
*/
public static function birthYearError(): string {
return sprintf(
/* translators: %d: the earliest birth year the form accepts. */
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
self::MIN_BIRTH_YEAR
);
}
/** /**
* A child's birth year, or an empty string when none is recorded. * A child's birth year, or an empty string when none is recorded.
* *
+6 -6
View File
@@ -38,12 +38,12 @@ if (! defined('ABSPATH')) {
<input type="hidden" name="us_family_action" value="edit"> <input type="hidden" name="us_family_action" value="edit">
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>"> <input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
<p> <p>
<label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label> <label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required> <input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
</p> </p>
<p> <p>
<label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label> <label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="child_birth_year" id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>"> <input type="number" name="child_birth_year" required id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button> <button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
@@ -76,12 +76,12 @@ if (! defined('ABSPATH')) {
<h4><?php esc_html_e('Add a student', 'unsupervised-schedular'); ?></h4> <h4><?php esc_html_e('Add a student', 'unsupervised-schedular'); ?></h4>
<p> <p>
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label> <label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-child-name" required> <input type="text" name="child_name" id="us-child-name" required>
</p> </p>
<p> <p>
<label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label> <label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="child_birth_year" id="us-child-birth-year" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>"> <input type="number" name="child_birth_year" id="us-child-birth-year" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label> <label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
+4 -4
View File
@@ -86,12 +86,12 @@ if (! defined('ABSPATH')) {
<?php /* The first block is the template the "Add another student" button clones. */ ?> <?php /* The first block is the template the "Add another student" button clones. */ ?>
<div class="us-child" data-child-index="0"> <div class="us-child" data-child-index="0">
<p> <p>
<label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?></label> <label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="children[0][name]" id="us-child-0-name"> <input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
</p> </p>
<p> <p>
<label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label> <label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>"> <input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<?php foreach ($accountQuestions as $question) : ?> <?php foreach ($accountQuestions as $question) : ?>
<?php <?php
+76 -10
View File
@@ -38,7 +38,11 @@ class RegistrationPageTest extends TestCase
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v); Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v); Functions\when('sanitize_email')->alias(static fn ($v) => $v);
Functions\when('absint')->alias(static fn ($v) => (int) $v); Functions\when('absint')->alias(static fn ($v) => (int) $v);
Functions\when('current_time')->justReturn('2024-01-01 00:00:00'); // The birth-year check reads current_time('Y'), so answer that format
// properly rather than leaving it to cast out of the datetime string.
Functions\when('current_time')->alias(
static fn (string $type = 'mysql'): string => 'Y' === $type ? '2024' : '2024-01-01 00:00:00'
);
Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null); Functions\when('wp_enqueue_script')->justReturn(null);
@@ -619,7 +623,7 @@ class RegistrationPageTest extends TestCase
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => 'Violin']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
// An untouched spare block is dropped, not rejected. // An untouched spare block is dropped, not rejected.
['name' => ' ', 'birth_year' => '', 'answers' => []], ['name' => ' ', 'birth_year' => '', 'answers' => []],
], ],
@@ -634,7 +638,7 @@ class RegistrationPageTest extends TestCase
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->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); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')->andReturn(102);
$recorded = []; $recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing( $this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
@@ -670,6 +674,68 @@ class RegistrationPageTest extends TestCase
self::assertStringContainsString('at least one student', $result); self::assertStringContainsString('at least one student', $result);
} }
/**
* A block the guardian actually typed into is theirs to correct, not ours to
* discard — only a wholly untouched spare is dropped. Losing the birth year
* they filled in and registering a nameless student would be worse than
* telling them what is missing.
*/
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => '', 'birth_year' => '2017', 'answers' => []],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'give each student a name',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/**
* @dataProvider rejectedBirthYears
*/
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'birth year',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string}> */
public static function rejectedBirthYears(): array
{
return [
'left blank' => [''],
'a full date' => ['2015-04-02'],
'in the future' => ['2027'],
];
}
/** /**
* Required per-child answers are validated before any user exists, so a * Required per-child answers are validated before any user exists, so a
* missing one never leaves a half-registered family behind. * missing one never leaves a half-registered family behind.
@@ -681,8 +747,8 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => ' ']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
], ],
]; ];
@@ -708,8 +774,8 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '', 'answers' => []], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => 'Alan', 'birth_year' => '', 'answers' => []], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
], ],
]; ];
@@ -717,8 +783,8 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '') $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')
->andReturn(new \WP_Error('link_failed', 'Nope.')); ->andReturn(new \WP_Error('link_failed', 'Nope.'));
$deleted = []; $deleted = [];
@@ -745,7 +811,7 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'accept' => [3], 'accept' => [3],
'children' => [['name' => 'Ada', 'birth_year' => '', 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
]; ];
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3); $version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
+28 -12
View File
@@ -116,20 +116,35 @@ class GuardianServiceTest extends TestCase
Functions\expect('wp_delete_user')->once()->with(42); Functions\expect('wp_delete_user')->once()->with(42);
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada')); self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada', '2015'));
} }
/** /**
* @dataProvider unusableBirthYears * @dataProvider unusableBirthYears
*/ */
public function testCreateChildClearsAnUnusableBirthYear(string $submitted): void public function testCreateChildRefusesAnUnusableBirthYear(string $submitted): void
{ {
Functions\when('wp_insert_user')->justReturn(42); // Refused before anything is written, so no orphan user is left behind.
$this->guardians->shouldReceive('insert')->once()->andReturn(7); Functions\expect('wp_insert_user')->never();
$this->guardians->shouldNotReceive('insert');
$this->service->createChild(5, 'Ada', $submitted); $result = $this->service->createChild(5, 'Ada', $submitted);
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []); self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
self::assertArrayNotHasKey(42, $this->meta);
}
/** @dataProvider unusableBirthYears */
public function testUpdateChildRefusesAnUnusableBirthYear(string $submitted): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user')->never();
$result = $this->service->updateChild(5, 42, 'Ada', $submitted);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
} }
/** @return array<string, array{string}> */ /** @return array<string, array{string}> */
@@ -142,6 +157,7 @@ class GuardianServiceTest extends TestCase
'too many digits' => ['20155'], 'too many digits' => ['20155'],
'before 1900' => ['1899'], 'before 1900' => ['1899'],
'later than today' => ['2027'], 'later than today' => ['2027'],
'left blank' => [''],
]; ];
} }
@@ -230,9 +246,8 @@ class GuardianServiceTest extends TestCase
} }
/** /**
* Saving a child drops the legacy full date. Without that, clearing the birth * Saving a child drops the legacy full date, so the fallback above can never
* year on a child who predates the change would leave the old date behind for * outrank a year the guardian has since corrected by hand.
* the fallback above to resurrect on the next read.
*/ */
public function testSavingAChildClearsTheLegacyDateOfBirth(): void public function testSavingAChildClearsTheLegacyDateOfBirth(): void
{ {
@@ -241,15 +256,16 @@ class GuardianServiceTest extends TestCase
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\when('wp_update_user')->justReturn(42); Functions\when('wp_update_user')->justReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '')); self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2016'));
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []); self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []); self::assertSame('2016', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
// The corrected year is what is read back, not the year of the old date.
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]); $this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace')); Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('', $this->service->children(5)[0]['birth_year']); self::assertSame('2016', $this->service->children(5)[0]['birth_year']);
} }
public function testBookableStudentsIsJustTheUserWithoutChildren(): void public function testBookableStudentsIsJustTheUserWithoutChildren(): void