CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Every account-signup question was asked of everybody who registered, on the same terms: "school and grade" had to be put to an adult signing themselves up, and a question a studio needed answered for each student could only be made required by demanding it of everyone. A question now carries an audience — everyone, or only the students someone registers on behalf of — and its own required flag for each side, so optional for you and required for every student you enrol is expressible. Both settings are account-scope only: an offering asks its questions once, about the student being booked, so there is no second audience to differ from, and an offering question mirrors its single "required" into both columns. Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild() rather than the raw flags, so a students-only question can neither block the account holder nor have an answer filed against them by a crafted post. The family screen, which only ever adds a student, is held to the students' rule. is_required_child arrives from dbDelta defaulting to 0, which would quietly stop every existing required question being required of the students a guardian registers — the case it most likely existed for. A one-time backfill copies is_required across, guarded by its own option so a question later made optional for students stays that way. Closes #163 Co-Authored-By: Claude Opus 5 <[email protected]>
1404 lines
60 KiB
PHP
1404 lines
60 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['guardians']->shouldReceive('setBirthYear')->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', 'birth_year' => '1990' ];
|
|
|
|
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', 'birth_year' => '1990' ];
|
|
|
|
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]', 'birth_year' => '1990' ];
|
|
|
|
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]', 'birth_year' => '1990' ];
|
|
|
|
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]', 'birth_year' => '1990' ];
|
|
|
|
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]', 'birth_year' => '1990' ];
|
|
|
|
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',
|
|
'birth_year' => '1990',
|
|
'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', 'birth_year' => '1990' ];
|
|
$_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',
|
|
'birth_year' => '1990',
|
|
'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',
|
|
'birth_year' => '1990',
|
|
'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));
|
|
}
|
|
|
|
/**
|
|
* Under "both" the account holder is a student too, so the studio's
|
|
* questions are asked of them as well as of each student they add. Before
|
|
* this they were asked per student only, and the account holder's own
|
|
* answers were never collected or stored.
|
|
*/
|
|
public function testBothRecordsAnswersForTheAccountHolderAndEachStudent(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1990',
|
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
|
'us_answers' => ['7' => 'Cello'],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
|
];
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
$this->stubInviteSuccess();
|
|
|
|
$recorded = [];
|
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
|
static function (Answer $answer) use (&$recorded): int {
|
|
$recorded[] = [$answer->studentId, $answer->answerValue];
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
|
|
// The student's answer against the student, the account holder's against
|
|
// themselves — not one answer shared between them.
|
|
self::assertEqualsCanonicalizing([[101, 'Piano'], [42, 'Cello']], $recorded);
|
|
}
|
|
|
|
public function testBothRejectsAnUnansweredQuestionForTheAccountHolder(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1990',
|
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
|
'us_answers' => ['7' => ' '],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
|
];
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
|
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
|
|
|
// The message names nobody else — the student's answer was fine.
|
|
self::assertStringContainsString('Please answer all required registration questions.', $result);
|
|
self::assertStringNotContainsString('for each student', $result);
|
|
}
|
|
|
|
/**
|
|
* A pure guardian is not a student, so the questions are theirs to answer
|
|
* per student and never about them. Anything posted for them is ignored.
|
|
*/
|
|
public function testRegisteringOnlyForStudentsStoresNoAnswersForTheAccountHolder(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'us_answers' => ['7' => 'Should be ignored'],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
|
];
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
$this->stubInviteSuccess();
|
|
|
|
$students = [];
|
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
|
static function (Answer $answer) use (&$students): int {
|
|
$students[] = $answer->studentId;
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
self::assertSame([101], $students);
|
|
}
|
|
|
|
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, isRequiredChild: true, 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', 'birth_year' => '1990'];
|
|
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* The account holder is a student under "self" and "both", so they give the
|
|
* same birth year every other student does — and it is stored against their
|
|
* own account under the same meta key a child's uses.
|
|
*
|
|
* @dataProvider modesWhereTheAccountHolderIsAStudent
|
|
*/
|
|
public function testTheAccountHoldersBirthYearIsRecordedWhenTheyAreAStudent(string $mode, bool $withChildren): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1988',
|
|
'us_registering_for' => $mode,
|
|
];
|
|
|
|
if ($withChildren) {
|
|
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
}
|
|
|
|
$this->stubInviteSuccess();
|
|
|
|
$this->ctx['guardians']->shouldReceive('setBirthYear')->once()->with(42, '1988');
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
}
|
|
|
|
/** @return array<string, array{string, bool}> */
|
|
public static function modesWhereTheAccountHolderIsAStudent(): array
|
|
{
|
|
return [
|
|
'just myself' => [RegistrationPage::FOR_SELF, false],
|
|
'myself and students' => [RegistrationPage::FOR_BOTH, true],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Missing or nonsense years are refused before a single user is created, the
|
|
* same way a student's is — the browser's `required` cannot be trusted here
|
|
* because the panel is hidden for a pure guardian.
|
|
*
|
|
* @dataProvider unusableBirthYears
|
|
*/
|
|
public function testAnUnusableBirthYearForTheAccountHolderIsRejected(string $submitted): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => $submitted,
|
|
];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
|
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
|
|
|
// Addressed to the person filling the form in, not to "each student".
|
|
self::assertStringContainsString('Please give your birth year', $result);
|
|
self::assertStringNotContainsString('each student', $result);
|
|
}
|
|
|
|
/** @return array<string, array{string}> */
|
|
public static function unusableBirthYears(): array
|
|
{
|
|
return [
|
|
'missing' => [''],
|
|
'two digits' => ['88'],
|
|
'not a year' => ['nineteen'],
|
|
'in future' => ['3000'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A pure guardian is not a student, so no birth year is asked of them and
|
|
* none is stored — anything posted for one is ignored, exactly as their
|
|
* answers are.
|
|
*/
|
|
public function testNoBirthYearIsStoredForAGuardianWhoIsNotAStudent(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => 'should be ignored',
|
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
|
];
|
|
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
$this->stubInviteSuccess();
|
|
|
|
$this->ctx['guardians']->shouldNotReceive('setBirthYear');
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
}
|
|
|
|
/**
|
|
* Everything the studio needs is asked on one page: the account holder's own
|
|
* birth year and questions sit above the students they are adding, and there
|
|
* is no second step to advance to.
|
|
*/
|
|
public function testTheFormAsksTheAccountHoldersQuestionsAboveTheStudents(): void
|
|
{
|
|
$this->stubRenderContext();
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('name="birth_year"', $html);
|
|
self::assertStringContainsString('name="us_answers[7]"', $html);
|
|
|
|
// One page, one submit: no "Next", no step panels.
|
|
self::assertStringNotContainsString('us-reg-next', $html);
|
|
self::assertStringNotContainsString('data-step', $html);
|
|
|
|
self::assertLessThan(
|
|
strpos($html, 'id="us-children"'),
|
|
strpos($html, 'name="us_answers[7]"'),
|
|
'The account holder answers the questions above the students they are adding.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A "students only" question describes a child being registered, so it is put
|
|
* to each student and never to the account holder about themselves.
|
|
*/
|
|
public function testAStudentsOnlyQuestionIsAskedOfTheStudentsAndNotOfTheAccountHolder(): void
|
|
{
|
|
$this->stubRenderContext();
|
|
|
|
$question = new Question(
|
|
null,
|
|
'School and grade',
|
|
scope: Question::SCOPE_ACCOUNT,
|
|
audience: Question::AUDIENCE_CHILD,
|
|
isRequiredChild: true,
|
|
id: 7
|
|
);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringNotContainsString('name="us_answers[7]"', $html);
|
|
self::assertStringContainsString('name="children[0][answers][7]"', $html);
|
|
}
|
|
|
|
/**
|
|
* The two required flags are read where each applies: the browser is asked to
|
|
* enforce the account holder's, and the students' block carries the marker
|
|
* without the attribute (it may not be in play at all).
|
|
*/
|
|
public function testTheFormMarksAQuestionRequiredWhereItActuallyIs(): void
|
|
{
|
|
$this->stubRenderContext();
|
|
|
|
$question = new Question(
|
|
null,
|
|
'Previous experience',
|
|
scope: Question::SCOPE_ACCOUNT,
|
|
isRequired: false,
|
|
isRequiredChild: true,
|
|
id: 7
|
|
);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
// No `required` attribute on the account holder's copy, and no marker on
|
|
// its label — they may leave it blank.
|
|
self::assertStringContainsString('<input type="text" name="us_answers[7]" id="us-reg-q-7">', $html);
|
|
self::assertStringContainsString('<label for="us-reg-q-7">Previous experience</label>', $html);
|
|
|
|
// The student's copy is marked required, without the attribute: the block
|
|
// may not be in play at all, so the server is what enforces it.
|
|
self::assertStringContainsString('<label for="us-child-0-q-7">Previous experience <span class="us-required" aria-hidden="true">*</span></label>', $html);
|
|
self::assertStringContainsString('<input type="text" name="children[0][answers][7]" id="us-child-0-q-7">', $html);
|
|
}
|
|
|
|
/**
|
|
* The point of the two flags: an adult signing themselves up can leave the
|
|
* question blank, while every student they enrol must answer it.
|
|
*/
|
|
public function testAQuestionOptionalForYouIsStillRequiredOfEachStudent(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1990',
|
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
|
'us_answers' => ['7' => ' '],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => ' ']]],
|
|
];
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
|
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
|
|
|
// The student's blank is what stopped it — the account holder's was fine.
|
|
self::assertStringContainsString('for each student', $result);
|
|
}
|
|
|
|
public function testTheAccountHolderMayLeaveBlankWhatTheirStudentsMustAnswer(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1990',
|
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
|
'us_answers' => ['7' => ' '],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
|
];
|
|
|
|
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
$this->stubInviteSuccess();
|
|
|
|
$students = [];
|
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
|
static function (Answer $answer) use (&$students): int {
|
|
$students[] = $answer->studentId;
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
|
|
// Only the student answered, so only the student has an answer stored.
|
|
self::assertSame([101], $students);
|
|
}
|
|
|
|
/**
|
|
* A question the account holder is never shown cannot be one they are held
|
|
* to, nor one an answer can be filed against them for — a crafted post that
|
|
* supplies both is ignored on both counts.
|
|
*/
|
|
public function testAStudentsOnlyQuestionNeitherBlocksNorStoresAgainstTheAccountHolder(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'birth_year' => '1990',
|
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
|
'us_answers' => ['7' => 'Crafted by hand'],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Grade 4']]],
|
|
];
|
|
|
|
$question = new Question(
|
|
null,
|
|
'School and grade',
|
|
isRequired: true,
|
|
scope: Question::SCOPE_ACCOUNT,
|
|
audience: Question::AUDIENCE_CHILD,
|
|
isRequiredChild: true,
|
|
id: 7
|
|
);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
$this->stubInviteSuccess();
|
|
|
|
$recorded = [];
|
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
|
static function (Answer $answer) use (&$recorded): int {
|
|
$recorded[] = [$answer->studentId, $answer->answerValue];
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
self::assertSame([[101, 'Grade 4']], $recorded);
|
|
}
|
|
}
|