Let parents register once and book for their children
A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -10,9 +10,11 @@ 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;
|
||||
@@ -63,6 +65,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
|
||||
|
||||
$this->ctx['page'] = new RegistrationPage(
|
||||
$invites,
|
||||
@@ -74,6 +77,7 @@ class RegistrationPageTest extends TestCase
|
||||
$questions,
|
||||
$answers,
|
||||
$access,
|
||||
$this->ctx['guardians'],
|
||||
);
|
||||
|
||||
$_POST = [];
|
||||
@@ -444,6 +448,7 @@ class RegistrationPageTest extends TestCase
|
||||
$this->ctx['questions'],
|
||||
$this->ctx['answers'],
|
||||
$this->ctx['access'],
|
||||
$this->ctx['guardians'],
|
||||
]
|
||||
)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
|
||||
@@ -600,4 +605,192 @@ class RegistrationPageTest extends TestCase
|
||||
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' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
|
||||
// An untouched spare block is dropped, not rejected.
|
||||
['name' => ' ', 'dob' => '', '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-04-02')->andReturn(101);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->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);
|
||||
}
|
||||
|
||||
public function testGuardianSignupWithNoChildrenIsRejected(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [['name' => '', 'dob' => '', '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 child', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']],
|
||||
],
|
||||
];
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
||||
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
|
||||
]);
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||
|
||||
self::assertStringContainsString('for each child', $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' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'children' => [
|
||||
['name' => 'Ada', 'dob' => '', 'answers' => []],
|
||||
['name' => 'Alan', 'dob' => '', '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', '')->andReturn(101);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
|
||||
->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' => 'password123',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'accept' => [3],
|
||||
'children' => [['name' => 'Ada', 'dob' => '', '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' => 'password123', 'display_name' => 'Ada'];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
Functions\when('is_wp_error')->justReturn(false);
|
||||
|
||||
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||
Functions\expect('wp_set_current_user')->once();
|
||||
Functions\expect('wp_set_auth_cookie')->once();
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user