Files
unsupervised-scheduler/tests/Unit/Auth/RegistrationPageTest.php
T
thatguygriffandClaude Opus 5 76caf178f0
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 40s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
Say "student" and "profile" in the UI, not "child" and "family"
Sweep the translatable strings across the frontend templates, the admin
screens, the editor previews and the block inserter entry. Nothing else
moves: the database columns, request parameters, form field names, CSS
classes, the us_family shortcode and the us-scheduler/family block name are
contracts with existing installs and with post content people have already
saved, so renaming them would break sites for no user-visible gain.

Two strings are reworded rather than swapped, because the direct
substitution reads wrong:

- The students list said "Child of Jane" and now says "Managed by Jane".
  "Student of Jane" would read as a teacher's pupil, which is exactly the
  wrong idea in a music studio.
- A managed account is now "a managed student account" rather than "a
  student account", which would not distinguish it from the account holder.

The guardian feature doc gains a short section on the split, so the next
person to work on it does not read the mismatch as drift and "fix" it.

Closes #144

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:34:55 -03:00

797 lines
34 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class RegistrationPageTest extends TestCase
{
/** @var array<string, mixed> */
private array $ctx;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_text_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('absint')->alias(static fn ($v) => (int) $v);
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
$invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class);
$questions = Mockery::mock(QuestionRepository::class);
$answers = Mockery::mock(AnswerRepository::class);
$policies->shouldReceive('findForScope')->andReturn([])->byDefault();
$questions->shouldReceive('findByScope')->andReturn([])->byDefault();
$answers->shouldReceive('insert')->andReturn(1)->byDefault();
$access = Mockery::mock(GroupAccessRepository::class);
$access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault();
$this->ctx = [
'invites' => $invites,
'policies' => $policies,
'questions' => $questions,
'answers' => $answers,
'access' => $access,
'mailer' => Mockery::mock(RegistrationMailer::class),
'settings' => Mockery::mock(StudioSettings::class),
];
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
$this->ctx['page'] = new RegistrationPage(
$invites,
$policies,
$this->ctx['versions'],
$this->ctx['acceptances'],
$this->ctx['settings'],
$this->ctx['mailer'],
$questions,
$answers,
$access,
$this->ctx['guardians'],
);
$_POST = [];
}
protected function tearDown(): void
{
$_POST = [];
$_GET = [];
$_REQUEST = [];
parent::tearDown();
}
/** Stub everything render() needs on a logged-out GET request. */
private function stubRenderContext(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
Functions\when('wp_nonce_field')->justReturn('');
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
}
private function submit(?Invite $invite, bool $open): string
{
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
return (string) $method->invoke($this->ctx['page'], $invite, $open);
}
public function testInviteBranchCreatesAndLogsInTheStudent(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
$invite = new Invite(email: '[email protected]', token: 'hash');
self::assertSame('invite', $this->submit($invite, false));
}
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\when('wp_set_current_user')->justReturn(null);
Functions\when('wp_set_auth_cookie')->justReturn(null);
// A personal invite tied to a class grant links the new account to it.
$this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('[email protected]', 42)->andReturn(true);
$invite = new Invite(email: '[email protected]', token: 'hash', offeringId: 8);
self::assertSame('invite', $this->submit($invite, false));
}
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
// markPending internals
Functions\when('wp_generate_password')->justReturn('rawtok');
Functions\when('update_user_meta')->justReturn(true);
// confirmUrl internals
Functions\when('get_option')->justReturn(0);
Functions\when('home_url')->justReturn('http://home.test/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string'));
// No invite acceptance and no auto-login in the open branch.
$this->ctx['invites']->shouldReceive('markAccepted')->never();
Functions\expect('wp_set_auth_cookie')->never();
self::assertSame('confirm', $this->submit(null, true));
}
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_generate_password')->justReturn('rawtok');
// confirmUrl internals
Functions\when('get_option')->justReturn(0);
Functions\when('home_url')->justReturn('http://home.test/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
// The auto-approve marker must be set alongside the pending metas.
$metas = [];
Functions\when('update_user_meta')->alias(static function (int $id, string $key, $value) use (&$metas): bool {
$metas[$key] = $value;
return true;
});
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string'));
// The link is multi-use: never marked accepted, and no auto-login.
$this->ctx['invites']->shouldReceive('markAccepted')->never();
Functions\expect('wp_set_auth_cookie')->never();
$invite = new Invite(
email: '',
token: 'hash',
createdAt: '2024-01-01 00:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2024-02-01 23:59:59',
id: 9
);
// Registration mode is invite-only (open = false): the group link still works.
self::assertSame('confirm_group', $this->submit($invite, false));
self::assertSame('1', $metas['us_auto_approve'] ?? null);
}
public function testGroupInviteRendersEditableEmailField(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
Functions\when('wp_nonce_field')->justReturn('');
// Invite-only mode: only the group link grants access to the form.
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$invite = new Invite(
email: '',
token: 'hash',
createdAt: '2024-01-01 00:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2024-02-01 23:59:59',
id: 9
);
$this->ctx['invites']->shouldReceive('findByToken')
->once()
->with(Invite::hashToken('raw-token'))
->andReturn($invite);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('<form', $html);
self::assertStringContainsString('name="email"', $html);
self::assertStringNotContainsString('readonly', $html);
}
public function testClosedModeWithoutInviteReturnsError(): void
{
$result = $this->submit(null, false);
self::assertNotSame('invite', $result);
self::assertNotSame('confirm', $result);
self::assertNotSame('', $result);
}
public function testConfirmedEmailShowsSignInLinkInsteadOfForm(): void
{
$_GET = ['us_confirmed' => '1'];
$this->stubRenderContext();
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('us-success', $html);
self::assertStringContainsString('http://home.test/wp-login.php', $html);
self::assertStringNotContainsString('<form', $html);
}
public function testConfirmedSignInLinkUsesConfiguredLoginPage(): void
{
$_GET = ['us_confirmed' => '1'];
$this->stubRenderContext();
Functions\expect('get_permalink')->once()->with(7)->andReturn('http://home.test/sign-in/');
$html = $this->ctx['page']->render(['login_page_id' => 7]);
self::assertStringContainsString('http://home.test/sign-in/', $html);
self::assertStringNotContainsString('wp-login.php', $html);
}
public function testRegistrationFormRendersWithoutConfirmationFlag(): void
{
$this->stubRenderContext();
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('<form', $html);
self::assertStringNotContainsString('us-success', $html);
}
public function testExpiredConfirmationStillShowsForm(): void
{
$_GET = ['us_confirmed' => 'expired'];
$this->stubRenderContext();
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('us-error', $html);
self::assertStringContainsString('<form', $html);
}
public function testValidInviteRendersEmailPrefilledAndLocked(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
$this->stubRenderContext();
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
$this->ctx['invites']->shouldReceive('findByToken')
->once()
->with(Invite::hashToken('raw-token'))
->andReturn($invite);
$html = $this->ctx['page']->render([]);
// The invited address is shown read-only; there is no editable email input.
self::assertStringContainsString('value="[email protected]" readonly', $html);
self::assertStringNotContainsString('name="email"', $html);
}
public function testStaleInviteWithOpenRegistrationShowsEditableEmail(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
$this->stubRenderContext();
// Already-redeemed invite: not acceptable, so the open-registration form
// must collect an email rather than showing the stale locked address.
$invite = new Invite(
email: '[email protected]',
token: 'hash',
status: Invite::STATUS_ACCEPTED,
createdAt: '2024-01-01 00:00:00',
id: 9
);
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('name="email"', $html);
self::assertStringNotContainsString('[email protected]', $html);
self::assertStringNotContainsString('readonly', $html);
}
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
$policy = new Policy(title: 'Terms', slug: 'terms', currentVersionId: 3);
$version = new PolicyVersion(policyId: 1, versionNumber: 1, status: PolicyVersion::STATUS_PUBLISHED, id: 3);
$this->ctx['policies']->shouldReceive('findForScope')->andReturn([ $policy ]);
$versionRepo = (new \ReflectionProperty(RegistrationPage::class, 'versions'))->getValue($this->ctx['page']);
$versionRepo->shouldReceive('findById')->with(3)->andReturn($version);
$result = $this->submit(null, true);
self::assertNotSame('confirm', $result);
self::assertNotSame('', $result);
}
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
$question = new Question(null, 'Emergency contact', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
// A missing required answer must be caught before any account is created.
Functions\expect('wp_insert_user')->never();
$this->ctx['answers']->shouldReceive('insert')->never();
$result = $this->submit(null, true);
self::assertNotSame('confirm', $result);
self::assertNotSame('', $result);
}
public function testRecordsAccountAnswersOnSuccess(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Ada',
'us_answers' => [ '5' => 'By a friend' ],
];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
$this->ctx['invites']->shouldReceive('markAccepted')->once();
$question = new Question(null, 'How did you hear about us?', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
// The answer is written against the new user (account scope).
$this->ctx['answers']->shouldReceive('insert')
->once()
->with(Mockery::on(static function (Answer $answer): bool {
return $answer->registrationType === Answer::REG_ACCOUNT
&& $answer->registrationId === 42
&& $answer->studentId === 42
&& $answer->questionId === 5
&& $answer->answerValue === 'By a friend';
}))
->andReturn(1);
$invite = new Invite(email: '[email protected]', token: 'hash');
self::assertSame('invite', $this->submit($invite, false));
}
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('get_permalink')->justReturn('http://home.test/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
// The cookie must be set here — during template_redirect, before output —
// which is the whole point of processing the submit outside render().
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$page = Mockery::mock(
RegistrationPage::class,
[
$this->ctx['invites'],
$this->ctx['policies'],
$this->ctx['versions'],
$this->ctx['acceptances'],
$this->ctx['settings'],
$this->ctx['mailer'],
$this->ctx['questions'],
$this->ctx['answers'],
$this->ctx['access'],
$this->ctx['guardians'],
]
)->makePartial()->shouldAllowMockingProtectedMethods();
$captured = '';
$page->shouldReceive('redirect')->once()->with(Mockery::on(static function (string $url) use (&$captured): bool {
$captured = $url;
return true;
}));
$page->maybeHandleSubmit();
self::assertStringContainsString('us_registered=invite', $captured);
}
public function testMaybeHandleSubmitStoresValidationErrorWithoutRedirecting(): void
{
// Too-short password: handleSubmit returns an error and no redirect fires.
$_POST = [ 'us_register' => '1', 'password' => 'short', 'display_name' => 'Ada' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
// A redirect would call exit; reaching the assertion proves none happened.
$this->ctx['page']->maybeHandleSubmit();
$error = (new \ReflectionProperty(RegistrationPage::class, 'submitError'))->getValue($this->ctx['page']);
self::assertNotSame('', $error);
}
public function testInviteSuccessRedirectShowsLoggedInWelcome(): void
{
// After the PRG redirect the student is logged in; the us_registered flag
// distinguishes a just-completed signup from an already-logged-in visitor.
$_GET = [ 'us_registered' => 'invite' ];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('us-success', $html);
self::assertStringContainsString('now logged in', $html);
// No page chosen: the sign-in-screen fallback is useless to someone who
// is already signed in, so no link is offered at all.
self::assertStringNotContainsString('<a href', $html);
}
public function testInviteSuccessLinksToTheChosenPage(): void
{
$_GET = [ 'us_registered' => 'invite' ];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
Functions\when('get_the_title')->justReturn('Book a Lesson');
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
self::assertStringContainsString('now logged in', $html);
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
// The link names its destination rather than saying "your account".
self::assertStringContainsString('Continue to Book a Lesson', $html);
}
public function testContinueLinkFallsBackToGenericWordingForAnUntitledPage(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('get_permalink')->justReturn('http://home.test/welcome/');
Functions\when('get_the_title')->justReturn(' ');
// An untitled page must not produce a link reading "Continue to ".
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
self::assertStringContainsString('Continue to your account', $html);
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
}
public function testAlreadyLoggedInVisitorIsLinkedToTheChosenPage(): void
{
// No us_registered flag: someone who simply happens to be signed in and
// lands on the registration page. They still need a way onward.
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
Functions\when('get_the_title')->justReturn('Book a Lesson');
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
self::assertStringContainsString('already have an account', $html);
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
self::assertStringContainsString('Continue to Book a Lesson', $html);
// Not the just-registered message — that branch needs its own flag.
self::assertStringNotContainsString('us-success', $html);
}
public function testAlreadyLoggedInVisitorGetsNoLinkWithoutAChosenPage(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('get_permalink')->justReturn(false);
// A deleted page resolves to false, which must not become a broken link.
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
self::assertStringContainsString('already have an account', $html);
self::assertStringNotContainsString('<a href', $html);
self::assertStringNotContainsString('<a href', $this->ctx['page']->render([]));
}
public function testContinueUrlIsNullWithoutAResolvablePage(): void
{
Functions\when('get_permalink')->justReturn(false);
self::assertNull($this->ctx['page']->continueUrl(0));
self::assertNull($this->ctx['page']->continueUrl(4));
}
public function testIsRegistrationCompleteOnlyForFinishedStates(): void
{
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
// [ query args, logged in, finished ]
$cases = [
'invited student, now logged in' => [['us_registered' => 'invite'], true, true],
'invited student, not logged in' => [['us_registered' => 'invite'], false, false],
'email confirmed, ready' => [['us_confirmed' => 'ready'], false, true],
'email confirmed, pending review' => [['us_confirmed' => '1'], false, true],
'confirmation link expired' => [['us_confirmed' => 'expired'], false, false],
'awaiting email confirmation' => [['us_registered' => 'confirm'], false, false],
'group signup awaiting confirm' => [['us_registered' => 'confirm_group'], false, false],
'plain page view' => [[], false, false],
];
foreach ($cases as $label => [$get, $loggedIn, $expected]) {
$_GET = $get;
Functions\when('is_user_logged_in')->justReturn($loggedIn);
self::assertSame($expected, $this->ctx['page']->isRegistrationComplete(), $label);
}
}
public function testInviteOnlyMessageCanBeCustomised(): void
{
// Closed registration and no invite → the invitation-only gate shows.
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
Functions\when('wp_nonce_field')->justReturn('');
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$html = $this->ctx['page']->render([ 'inviteOnlyMessage' => 'Ask the front desk for a link.' ]);
self::assertStringContainsString('Ask the front desk for a link.', $html);
self::assertStringNotContainsString('by invitation only', $html);
}
/**
* A guardian's signup creates one login-less child per filled block, links
* them, and records each child's answers against the child rather than the
* account holder — the questions describe the student, not the parent.
*/
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
// An untouched spare block is dropped, not rejected.
['name' => ' ', 'dob' => '', 'answers' => []],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
]);
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);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
$recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $a) use (&$recorded): int {
$recorded[] = [$a->studentId, $a->answerValue];
return 1;
}
);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
}
public function testGuardianSignupWithNoChildrenIsRejected(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => '', 'dob' => '', 'answers' => []]],
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
self::assertStringContainsString('at least one student', $result);
}
/**
* Required per-child answers are validated before any user exists, so a
* missing one never leaves a half-registered family behind.
*/
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString('for each student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* A family that half-created would leave the guardian unable to re-register
* and their children unconfirmed, so the whole signup is undone.
*/
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => []],
['name' => 'Alan', 'dob' => '', 'answers' => []],
],
];
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);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
->andReturn(new \WP_Error('link_failed', 'Nope.'));
$deleted = [];
$this->ctx['guardians']->shouldReceive('deleteUser')->andReturnUsing(
static function (int $id) use (&$deleted): void {
$deleted[] = $id;
}
);
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
self::assertStringContainsString('Could not create the account', $result);
self::assertSame([101, 42], $deleted);
}
/**
* The child is who the policy binds; the guardian is who agreed. Both are
* recorded, which is what makes the acceptance legally meaningful.
*/
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'accept' => [3],
'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]],
];
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
$this->ctx['policies']->shouldReceive('findForScope')->andReturn([
new Policy(title: 'Studio Terms', slug: 'terms', currentVersionId: 3, id: 1),
]);
$this->ctx['versions']->shouldReceive('findById')->with(3)->andReturn($version);
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);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$recorded = [];
$this->ctx['acceptances']->shouldReceive('insert')->andReturnUsing(
static function (PolicyAcceptance $a) use (&$recorded): int {
$recorded[] = [$a->studentId, $a->acceptedBy];
return 1;
}
);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
// The guardian agreed for themselves as an account holder, and for the child.
self::assertSame([[42, 42], [101, 42]], $recorded);
}
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
{
$_POST = ['password' => 'password123', 'display_name' => 'Ada'];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['guardians']->shouldNotReceive('createChild');
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
}