CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped
The assessment looked for three things: whether students can reach each other's bookings, whether payment settings can be dodged, and whether the plugin opens a way into the rest of the install. The student-isolation and payment paths held up. These are what did not. - The front-end login form told WordPress not to work out whether the site was secure, so on HTTPS every student's session cookie was issued without the Secure flag. wp_signon() only derives it from is_ssl() when the second argument is left at its default; an explicit false reads like "no preference" and is not. - The update check took whatever download URL the release API returned and handed it to core, which unpacks it over the installed plugin. The package must now be https on git.unsupervised.ca exactly, compared on the parsed host so a lookalike name cannot pass. - Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook signing key in wp_options. Removal is now a choice made in advance on Access -> Plugin removal: records are kept unless the owner opts in (with a typed confirmation), while credentials and the borrowed core registration settings go every time. - Open registration switches on the site-wide users_can_register and makes Student the default role, arming any other signup form on the site to mint students who could book and be billed immediately. The pending state is now decided once, on user_register, rather than by whichever form created the account. - Cancel and withdraw answered "not yours" differently from "does not exist", which let a signed-in student enumerate the studio's bookings. Both now give the same 404. Co-Authored-By: Claude Opus 5 <[email protected]>
1440 lines
61 KiB
PHP
1440 lines
61 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\Auth\RegistrationStatus;
|
|
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;
|
|
|
|
/** @var list<string> User meta keys cleared during the submit under test. */
|
|
private array $clearedMeta = [];
|
|
|
|
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);
|
|
|
|
// Both success branches clear pending meta on the account they just
|
|
// created — the invited student is approved outright, the self-signup is
|
|
// marked unconfirmed. Recorded rather than counted so a test can say
|
|
// which, without every other test having to expect the calls.
|
|
$this->clearedMeta = [];
|
|
$cleared = &$this->clearedMeta;
|
|
Functions\when('delete_user_meta')->alias(
|
|
static function (int $userId, string $key) use (&$cleared): bool {
|
|
$cleared[] = $key;
|
|
|
|
return 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));
|
|
}
|
|
|
|
/**
|
|
* The registration gate holds every student account created by an
|
|
* unauthenticated request, which is what a signup is — so the invite branch
|
|
* has to say that this one is different. Without it an invited student is
|
|
* logged straight in and then told they cannot book.
|
|
*/
|
|
public function testInvitedStudentIsApprovedRatherThanLeftAwaitingApproval(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
|
|
|
|
$this->stubInviteSuccess();
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash');
|
|
|
|
self::assertSame('invite', $this->submit($invite, false));
|
|
self::assertContains(RegistrationStatus::META_AWAITING_APPROVAL, $this->clearedMeta);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|