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]>
1032 lines
44 KiB
PHP
1032 lines
44 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);
|
|
// 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_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);
|
|
// Recorded on every successful signup; the tests that care assert on it.
|
|
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
|
|
|
|
$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);
|
|
}
|
|
|
|
/** 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
|
|
{
|
|
$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: '[email protected]', 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('[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' => 'thistle-marrow-42', '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' => 'thistle-marrow-42', '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' => 'thistle-marrow-42', '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' => 'thistle-marrow-42', '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' => '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: '[email protected]', 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: '[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' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'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: '[email protected]', 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' => '[email protected]',
|
|
'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: '[email protected]', token: 'hash'), false)
|
|
);
|
|
}
|
|
|
|
/** @return array<string, array{string, string}> */
|
|
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)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "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
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'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: '[email protected]', 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_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'children' => [
|
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
|
|
['name' => '', 'birth_year' => '2017', 'answers' => []],
|
|
],
|
|
];
|
|
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
|
|
|
self::assertStringContainsString(
|
|
'give each student a name',
|
|
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider rejectedBirthYears
|
|
*/
|
|
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
|
|
];
|
|
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
|
|
|
self::assertStringContainsString(
|
|
'birth year',
|
|
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
|
);
|
|
}
|
|
|
|
/** @return array<string, array{string}> */
|
|
public static function rejectedBirthYears(): array
|
|
{
|
|
return [
|
|
'left blank' => [''],
|
|
'a full date' => ['2015-04-02'],
|
|
'in the future' => ['2027'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Required per-child answers are validated before any user exists, so a
|
|
* missing one never leaves a half-registered family behind.
|
|
*/
|
|
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'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: '[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' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'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: '[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' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'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: '[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' => '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: '[email protected]', token: 'hash'), false));
|
|
}
|
|
}
|