Ask who the signup is for as a three-way choice
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped

Replaces the single "I'm registering as a parent or guardian" tick with
"Just myself" / "On behalf of one or more students" / "Both".

Radios, not checkboxes as the feedback put it: the three answers are
mutually exclusive, and "both" only means anything as a third choice
alongside the other two.

The tick could only ever say whether there were children to add. It could
not say whether the account holder was a student, so bookableStudents()
always offered them their own name and any guardian could book themselves a
lesson nobody meant to sell. "On behalf of" now records us_guardian_only and
leaves them out of the picker.

That flag is stored as the negative on purpose. Every account predating this
choice is a bookable student, and absence has to keep meaning exactly that,
or the picker would quietly stop offering people themselves on upgrade.
setGuardianOnly() clears the key rather than writing 0, so "not set" stays
the single spelling of "yes, a student". A guardian-only account with nobody
linked to it is still offered itself — an empty picker is no way to book at
all, and they can put the account right from the profile page.

An unrecognised or absent value reads as "just myself": the choice that
collects the least and grants the least. A missing radio must never be taken
as "register these children".

Bumps to 1.4.0.

The account holder's own questions stay out of play whenever students are
being added, "both" included — asking them there is #146.

Verified the form in a headless browser across all three choices: which
blocks show, which fields carry `required`, whether the account holder's
question panel is disabled, which submit is offered, and that switching back
to "just myself" leaves no hidden required field blocking submit.

Closes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-29 23:09:47 -03:00
co-authored by Claude Opus 5
parent 28046e0fd1
commit 4e5382e259
9 changed files with 319 additions and 43 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.3.1] ## [1.4.0]
### Added ### Added
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header. - An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
@@ -20,6 +20,7 @@ each change under the current top section as you work.
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered. - Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
### Changed ### Changed
- Signup now asks **"Who are you registering?"** as a three-way choice — **just myself**, **on behalf of one or more students**, or **both** — in place of the single parent/guardian tick. The tick could only ever say "I have children to add"; it could not say whether the account holder was a student themselves, so every account was offered its own name in the **Who is this for?** picker whether or not anyone meant to book them a lesson. Choosing *on behalf of* now leaves the account holder out of that picker. Existing accounts are unaffected and stay bookable, since the flag records only the new "not a student" case.
- 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. - 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.
+36 -16
View File
@@ -9,11 +9,12 @@
* the questions) inside a form marked `data-steps="1"`. Step two is hidden * the questions) inside a form marked `data-steps="1"`. Step two is hidden
* behind a "Next" button that only advances once step one passes native * behind a "Next" button that only advances once step one passes native
* validation. * validation.
* 2. **Parent/guardian.** The children section is hidden until the * 2. **Who are you registering?** The student section is hidden until the
* parent/guardian box is ticked, and "Add another child" clones the child * choice is "on behalf of students" or "both", and "Add another student"
* block. Ticking the box also takes the guardian's *own* question panel out * clones the student block. Either of those choices also takes the account
* of play — in guardian mode the questions are asked per child, so the * holder's *own* question panel out of play — the questions are then asked
* server ignores those answers and the browser must not demand them. * per student, so the server ignores those answers and the browser must not
* demand them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's * 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* own `wp.passwordStrength`) and a weak one is refused. The server applies * own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`. * its own, coarser rule regardless — see `Auth\PasswordPolicy`.
@@ -198,21 +199,38 @@
} }
function enhanceGuardian(form, steps) { function enhanceGuardian(form, steps) {
var toggle = form.querySelector('#us-is-guardian'); var choices = form.querySelectorAll('.us-registering-for');
var children = form.querySelector('#us-children'); var children = form.querySelector('#us-children');
if (!toggle || !children) { if (!choices.length || !children) {
return; return;
} }
var addButton = children.querySelector('.us-add-child'); var addButton = children.querySelector('.us-add-child');
var nextIndex = 1; var nextIndex = 1;
// The guardian's own question panel is only meaningful when they are /** The selected "who are you registering?" value; 'self' if somehow none is. */
// registering for themselves. Disabling it (rather than hiding it) is what function mode() {
// stops a `required` question the server will ignore from blocking submit. for (var i = 0; i < choices.length; i++) {
if (choices[i].checked) return choices[i].value;
}
return 'self';
}
/**
* Keep the form in step with the choice.
*
* Student blocks appear for "students" and "both". The account holder's
* own question panel is the mirror image: the studio's questions describe
* a student, so whenever students are being added they are asked per
* student instead, and the account holder's copy goes out of play.
* Disabling it rather than hiding it is what stops a `required` question
* the server will ignore from blocking submit.
*/
function sync() { function sync() {
children.hidden = !toggle.checked; var wantsStudents = mode() !== 'self';
children.hidden = !wantsStudents;
// Each student's name and birth year are required, but only once the // 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 // block is in play: a `required` field inside a hidden container makes
@@ -221,7 +239,7 @@
// enforces the same rule either way. // enforces the same rule either way.
var required = children.querySelectorAll('[data-us-child-required]'); var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) { for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked; required[r].required = wantsStudents;
} }
if (!steps) { if (!steps) {
@@ -230,19 +248,21 @@
var fields = steps.step2.querySelectorAll('input, select, textarea'); var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) { for (var i = 0; i < fields.length; i++) {
fields[i].disabled = toggle.checked; fields[i].disabled = wantsStudents;
} }
// With the questions out of play there is no second step to advance to, // With the questions out of play there is no second step to advance to,
// so "Next" would be a dead end — swap it for the submit. // so "Next" would be a dead end — swap it for the submit.
steps.next.hidden = toggle.checked; steps.next.hidden = wantsStudents;
if (steps.earlySubmit) { if (steps.earlySubmit) {
steps.earlySubmit.hidden = !toggle.checked; steps.earlySubmit.hidden = !wantsStudents;
} }
} }
toggle.addEventListener('change', sync); for (var c = 0; c < choices.length; c++) {
choices[c].addEventListener('change', sync);
}
sync(); sync();
if (addButton) { if (addButton) {
+37 -4
View File
@@ -140,10 +140,43 @@ apply to.
## Registration ## Registration
A **"I'm registering as a parent or guardian"** checkbox on the existing A **"Who are you registering?"** choice on the existing `[us_student_register]`
`[us_student_register]` form (all three signup paths — personal invite, group form (all three signup paths — personal invite, group link, self-approval), as
link, self-approval) reveals a repeatable child block. Ticking it requires at three radios:
least one child name.
| Choice | `us_registering_for` | Student blocks | Account holder is a student |
|---|---|---|---|
| Just myself | `self` | no | yes |
| On behalf of one or more students | `students` | yes | **no** |
| Both — myself and one or more students | `both` | yes | yes |
Radios rather than checkboxes because the three answers are mutually exclusive:
"both" only means anything as a third choice alongside the other two. Either
student-bearing choice requires at least one student name.
Anything unrecognised — a form posted without the field, an old cached page, a
crafted request — is read as `self`, the choice that collects the least and
grants the least. A missing radio must never be taken as "register these
children".
### The account holder as a student
This replaced a single "I'm registering as a parent or guardian" checkbox, which
could only say *whether there were children to add*. It could not say whether the
**account holder** was a student, so `bookableStudents()` always offered them
their own name and every guardian could book themselves a lesson nobody intended
to sell.
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
account holder out. The flag is stored as the **negative** deliberately: every
account predating the choice is a bookable student, and absence has to keep
meaning exactly that, or the picker would silently stop offering people
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
than writing `0`, so "not set" stays the one spelling of "yes, a student".
One guard: a guardian-only account with **nobody linked to it** is still offered
itself, because an empty picker is no way to book at all. They can put the
account right from the profile page.
Per child the form collects: Per child the form collects:
- **Name** (required) - **Name** (required)
+28 -5
View File
@@ -19,6 +19,15 @@ use Unsupervised\Schedular\Val;
class RegistrationPage { class RegistrationPage {
/** "Who are you registering?": the account holder, and nobody else. */
public const FOR_SELF = 'self';
/** Only other people — the account holder is not a student. */
public const FOR_STUDENTS = 'students';
/** The account holder *and* other people. */
public const FOR_BOTH = 'both';
/** Success signal: an invited student was created and logged in. */ /** Success signal: an invited student was created and logged in. */
private const RESULT_INVITE = 'invite'; private const RESULT_INVITE = 'invite';
@@ -306,14 +315,17 @@ class RegistrationPage {
// Registering as a parent/guardian turns the account-signup questions from // Registering as a parent/guardian turns the account-signup questions from
// "about you" into "about each child" — they describe the student // "about you" into "about each child" — they describe the student
// (instrument, level, school), not the person holding the account. // (instrument, level, school), not the person holding the account.
$isGuardian = $this->submittedIsGuardian(); $registeringFor = $this->submittedRegisteringFor();
// "Students" and "both" both collect student blocks; only "self" does not.
$isGuardian = self::FOR_SELF !== $registeringFor;
$children = $isGuardian ? $this->submittedChildren() : []; $children = $isGuardian ? $this->submittedChildren() : [];
$answers = $isGuardian ? [] : $this->submittedAnswers(); $answers = $isGuardian ? [] : $this->submittedAnswers();
// Everything is validated before a single user is created, so a bad child // Everything is validated before a single user is created, so a bad child
// block never leaves a half-registered family behind. // block never leaves a half-registered family behind.
if ( $isGuardian && [] === $children ) { if ( $isGuardian && [] === $children ) {
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 choose "Just myself" instead.', 'unsupervised-schedular' );
} }
// Name and birth year are required per student, and are checked here for // Name and birth year are required per student, and are checked here for
@@ -360,6 +372,10 @@ class RegistrationPage {
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId ); $this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
// Only "students" means the account holder is not a student themselves;
// "both" registers them alongside the people they book for.
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
if ( $isGuardian ) { if ( $isGuardian ) {
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId ); $failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
if ( '' !== $failure ) { if ( '' !== $failure ) {
@@ -508,12 +524,19 @@ class RegistrationPage {
} }
/** /**
* Whether the "I'm registering as a parent or guardian" box was ticked. * Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
* {@see FOR_BOTH}.
*
* Anything unrecognised — including a form posted without the field at all —
* falls back to "just myself", the choice that collects the least and grants
* the least. A missing radio must not be read as "register these children".
*/ */
private function submittedIsGuardian(): bool { private function submittedRegisteringFor(): string {
// The submit nonce is verified by the caller before this runs. // The submit nonce is verified by the caller before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing // phpcs:ignore WordPress.Security.NonceVerification.Missing
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) ); $value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
} }
/** /**
+43 -1
View File
@@ -34,6 +34,17 @@ class GuardianService {
*/ */
public const META_DOB = 'us_date_of_birth'; public const META_DOB = 'us_date_of_birth';
/**
* Set on an account that registered **only** to book for other people, so it
* is not offered as a student in its own right.
*
* Stored as the negative on purpose. Every account that existed before this
* choice was offered is a bookable student, and absence of the flag has to
* keep meaning exactly that — otherwise the picker would quietly stop
* offering people themselves on upgrade.
*/
public const META_GUARDIAN_ONLY = 'us_guardian_only';
/** /**
* The earliest birth year the form will accept. Old enough for any student a * 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` * studio will ever enrol, and late enough to reject a typo like `19` or `190`
@@ -228,7 +239,9 @@ class GuardianService {
* their kid is the error worth designing out. * their kid is the error worth designing out.
* *
* The guardian is still offered, last, so a parent taking lessons alongside * The guardian is still offered, last, so a parent taking lessons alongside
* their children can book for themselves from the same account. * their children can book for themselves from the same account — unless they
* said at signup that they are not a student, in which case offering them is
* an invitation to book a lesson nobody meant to buy.
* *
* @return list<array{id: int, name: string, is_self: bool}> * @return list<array{id: int, name: string, is_self: bool}>
*/ */
@@ -243,6 +256,13 @@ class GuardianService {
]; ];
} }
// A guardian-only account with nobody linked to it would otherwise get an
// empty list and no way to book at all. Offering them themselves is the
// lesser wrong: they can still correct the account from the profile page.
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
return $out;
}
$self = get_userdata( $userId ); $self = get_userdata( $userId );
$out[] = [ $out[] = [
@@ -254,6 +274,28 @@ class GuardianService {
return $out; return $out;
} }
/**
* Whether this account books only for other people. False for every account
* that predates the choice — see {@see META_GUARDIAN_ONLY}.
*/
public static function isGuardianOnly( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
}
/**
* Record whether this account is a student in its own right. Clears the flag
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
* they are a student".
*/
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
if ( $guardianOnly ) {
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
return;
}
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
}
/** /**
* A guardian's children, in link order, with the details the family and admin * A guardian's children, in link order, with the details the family and admin
* screens display. * screens display.
+20 -3
View File
@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use Unsupervised\Schedular\Auth\PasswordPolicy; use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Registration\Question; use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField; use Unsupervised\Schedular\Registration\QuestionField;
@@ -81,14 +82,30 @@ if (! defined('ABSPATH')) {
<fieldset class="us-guardian"> <fieldset class="us-guardian">
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend> <legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
<?php
/*
* Radios, not checkboxes: the three answers are mutually
* exclusive, and "both" only means anything as a third
* choice alongside the other two. "Just myself" is
* pre-selected because it is the commonest signup and the
* one that collects the least.
*/
$registeringForChoices = [
RegistrationPage::FOR_SELF => __('Just myself', 'unsupervised-schedular'),
RegistrationPage::FOR_STUDENTS => __('On behalf of one or more students', 'unsupervised-schedular'),
RegistrationPage::FOR_BOTH => __('Both — myself and one or more students', 'unsupervised-schedular'),
];
?>
<?php foreach ($registeringForChoices as $value => $label) : ?>
<p> <p>
<label> <label>
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1"> <input type="radio" name="us_registering_for" value="<?php echo esc_attr($value); ?>" class="us-registering-for"<?php checked($value, RegistrationPage::FOR_SELF); ?>>
<?php esc_html_e("I'm registering as a parent or guardian, for one or more students", 'unsupervised-schedular'); ?> <?php echo esc_html($label); ?>
</label> </label>
</p> </p>
<?php endforeach; ?>
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?> <?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?>
<div class="us-children" id="us-children"> <div class="us-children" id="us-children">
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p> <p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
+121 -7
View File
@@ -35,6 +35,8 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_unslash')->alias(static fn ($v) => $v); Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v); Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
// Every submit reads the "who are you registering?" radio through it.
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
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);
// Reached on every submit now that the email is validated before the // Reached on every submit now that the email is validated before the
@@ -74,6 +76,8 @@ class RegistrationPageTest extends TestCase
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class); $this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class); $this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['guardians'] = Mockery::mock(GuardianService::class); $this->ctx['guardians'] = Mockery::mock(GuardianService::class);
// Recorded on every successful signup; the tests that care assert on it.
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
$this->ctx['page'] = new RegistrationPage( $this->ctx['page'] = new RegistrationPage(
$invites, $invites,
@@ -109,6 +113,17 @@ class RegistrationPageTest extends TestCase
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true); $this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
} }
/** Everything the invite success branch touches once the account is created. */
private function stubInviteSuccess(): void
{
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
Functions\when('wp_set_current_user')->justReturn(null);
Functions\when('wp_set_auth_cookie')->justReturn(null);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
}
private function submit(?Invite $invite, bool $open): string private function submit(?Invite $invite, bool $open): string
{ {
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit'); $method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
@@ -624,7 +639,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
@@ -711,12 +726,111 @@ class RegistrationPageTest extends TestCase
); );
} }
/**
* "On behalf of students" is the one choice that says the account holder is
* not a student, so it is the one that sets the flag.
*/
public function testRegisteringOnlyForStudentsMarksTheAccountGuardianOnly(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, true);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* @dataProvider modesThatKeepTheAccountHolderAStudent
*/
public function testTheAccountHolderStaysAStudentForTheOtherTwoChoices(string $mode, bool $withChildren): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => $mode,
];
if ($withChildren) {
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
}
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/** @return array<string, array{string, bool}> */
public static function modesThatKeepTheAccountHolderAStudent(): array
{
return [
'just myself' => [RegistrationPage::FOR_SELF, false],
'myself and students' => [RegistrationPage::FOR_BOTH, true],
];
}
/**
* "Both" collects students exactly as "on behalf of" does — the only
* difference is whether the account holder is one of them.
*/
public function testBothStillRequiresAtLeastOneStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'children' => [],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString('at least one student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* A form posted without the radio — an old cached page, or a crafted
* request — must fall to the choice that collects and grants the least,
* never be read as "register these children".
*/
public function testAMissingOrUnknownChoiceFallsBackToJustMyself(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => 'something-else',
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->stubInviteSuccess();
// No student is created from children[] the caller never asked to register.
$this->ctx['guardians']->shouldNotReceive('createChild');
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
public function testGuardianSignupWithNoChildrenIsRejected(): void public function testGuardianSignupWithNoChildrenIsRejected(): void
{ {
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]], 'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
]; ];
@@ -740,7 +854,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => '', 'birth_year' => '2017', 'answers' => []], ['name' => '', 'birth_year' => '2017', 'answers' => []],
@@ -766,7 +880,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
]; ];
@@ -800,7 +914,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
@@ -827,7 +941,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [ 'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => []], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
@@ -864,7 +978,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'accept' => [3], 'accept' => [3],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
]; ];
+26
View File
@@ -17,6 +17,32 @@ abstract class TestCase extends BaseTestCase
Monkey\setUp(); Monkey\setUp();
Monkey\Functions\stubTranslationFunctions(); Monkey\Functions\stubTranslationFunctions();
Monkey\Functions\stubEscapeFunctions(); Monkey\Functions\stubEscapeFunctions();
// WordPress's form-state helpers. Stubbed here rather than per test
// because they are pure output helpers with no behaviour worth faking
// differently anywhere — a template either emits the attribute or not.
Monkey\Functions\when('checked')->alias(
static fn (mixed $checked, mixed $current = true, bool $echo = true): string
=> self::formAttribute('checked', $checked, $current, $echo)
);
Monkey\Functions\when('selected')->alias(
static fn (mixed $selected, mixed $current = true, bool $echo = true): string
=> self::formAttribute('selected', $selected, $current, $echo)
);
}
/** Mirrors WordPress's `__checked_selected_helper()`, echo included. */
private static function formAttribute(string $type, mixed $helper, mixed $current, bool $echo): string
{
// WordPress compares loosely, and templates rely on that: `checked($a, $b)`
// is routinely called with an int against a numeric string.
$result = $helper == $current ? " {$type}='{$type}'" : ''; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
if ($echo) {
echo $result; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed literal.
}
return $result;
} }
protected function tearDown(): void protected function tearDown(): void
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.3.1 * Version: 1.4.0
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.3.1'); define('USC_VERSION', '1.4.0');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));