*/ 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('