*/ 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); // Reached on every submit now that the email is validated before the // password, so the password can be checked against it. Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v)); Functions\when('absint')->alias(static fn ($v) => (int) $v); // 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_script')->justReturn(null); Functions\when('wp_localize_script')->justReturn(true); $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' => 'thistle-marrow-42', '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: 'a@b.test', token: 'hash'); self::assertSame('invite', $this->submit($invite, false)); } public function testInviteAcceptanceLinksClassGrantForTheEmail(): void { $_POST = [ 'password' => 'thistle-marrow-42', '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('a@b.test', 42)->andReturn(true); $invite = new Invite(email: 'a@b.test', token: 'hash', offeringId: 8); self::assertSame('invite', $this->submit($invite, false)); } public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void { $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; 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' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; 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('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(' '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(' 'expired']; $this->stubRenderContext(); $html = $this->ctx['page']->render([]); self::assertStringContainsString('us-error', $html); self::assertStringContainsString(' 'raw-token']; $this->stubRenderContext(); $invite = new Invite(email: 'invited@b.test', 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="invited@b.test" 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: 'used@b.test', 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('used@b.test', $html); self::assertStringNotContainsString('readonly', $html); } public function testRejectsWhenARequiredPolicyIsUnaccepted(): void { $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; 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' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; 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' => 'thistle-marrow-42', '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: 'a@b.test', token: 'hash'); self::assertSame('invite', $this->submit($invite, false)); } public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void { $_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', '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: 'a@b.test', 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(' '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('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' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']], // An untouched spare block is dropped, not rejected. ['name' => ' ', 'birth_year' => '', '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')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')->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: 'a@b.test', token: 'hash'), false)); self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded); } /** * The browser gates on zxcvbn, but that is advice a client can decline to * take. Nothing is created for a password the server refuses. * * @dataProvider refusedPasswords */ public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void { $_POST = [ 'email' => 'grace@studio.test', 'password' => $password, 'display_name' => 'Grace Hopper', ]; Functions\when('email_exists')->justReturn(false); Functions\expect('wp_insert_user')->never(); self::assertStringContainsString( $expected, $this->submit(new Invite(email: 'grace@studio.test', token: 'hash'), false) ); } /** @return array */ public static function refusedPasswords(): array { return [ 'too short' => ['abc123', 'at least'], 'a known password' => ['password123', 'commonly used'], 'barely any variety' => ['ababababab', 'repeated characters'], 'their own name' => ['grace-hopper-1906', 'name or email'], ]; } public function testSignupRefusesAnAddressThatIsNotAnEmail(): void { $_POST = [ 'email' => 'not-an-email', 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', ]; Functions\when('email_exists')->justReturn(false); Functions\expect('wp_insert_user')->never(); self::assertStringContainsString( 'valid email address', $this->submit(null, true) ); } public function testGuardianSignupWithNoChildrenIsRejected(): void { $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [['name' => '', 'birth_year' => '', 'answers' => []]], ]; Functions\when('email_exists')->justReturn(false); Functions\expect('wp_insert_user')->never(); $this->ctx['guardians']->shouldNotReceive('createChild'); $result = $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false); 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' => 'thistle-marrow-42', '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: 'a@b.test', token: 'hash'), false) ); } /** * @dataProvider rejectedBirthYears */ public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void { $_POST = [ 'password' => 'thistle-marrow-42', '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: 'a@b.test', token: 'hash'), false) ); } /** @return array */ 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 * missing one never leaves a half-registered family behind. */ public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void { $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], ['name' => 'Alan', 'birth_year' => '2017', '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: 'a@b.test', 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' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ ['name' => 'Ada', 'birth_year' => '2015', 'answers' => []], ['name' => 'Alan', 'birth_year' => '2017', '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', '2015')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017') ->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: 'a@b.test', 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' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'accept' => [3], 'children' => [['name' => 'Ada', 'birth_year' => '2015', '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: 'a@b.test', 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' => 'thistle-marrow-42', '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: 'a@b.test', token: 'hash'), false)); } }