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]>
561 lines
19 KiB
PHP
561 lines
19 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit;
|
|
|
|
use Brain\Monkey\Actions;
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Auth\LoginPage;
|
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
|
use Unsupervised\Schedular\BlockRegistrar;
|
|
use Unsupervised\Schedular\Booking\BookingPage;
|
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
|
use Unsupervised\Schedular\Guardian\FamilyPage;
|
|
|
|
/**
|
|
* Test double exposing editor-preview mode as a switch (the real detection
|
|
* relies on the REST_REQUEST constant, which cannot be toggled within a
|
|
* single PHP process) and recording redirects instead of exiting.
|
|
*/
|
|
class TestableBlockRegistrar extends BlockRegistrar
|
|
{
|
|
public bool $preview = false;
|
|
|
|
/** @var list<string> */
|
|
public array $redirects = [];
|
|
|
|
protected function isEditorPreview(): bool
|
|
{
|
|
return $this->preview;
|
|
}
|
|
|
|
protected function redirect(string $url): void
|
|
{
|
|
$this->redirects[] = $url;
|
|
}
|
|
}
|
|
|
|
class BlockRegistrarTest extends TestCase
|
|
{
|
|
private BookingPage&Mockery\MockInterface $bookingPage;
|
|
private LoginPage&Mockery\MockInterface $loginPage;
|
|
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
|
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
|
private FamilyPage&Mockery\MockInterface $familyPage;
|
|
private TestableBlockRegistrar $registrar;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->bookingPage = Mockery::mock(BookingPage::class);
|
|
$this->loginPage = Mockery::mock(LoginPage::class);
|
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
|
$this->familyPage = Mockery::mock(FamilyPage::class);
|
|
|
|
// Most requests are not a just-finished registration; the tests that
|
|
// exercise that path override this.
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')
|
|
->andReturn(false)
|
|
->byDefault();
|
|
|
|
$this->registrar = new TestableBlockRegistrar(
|
|
$this->bookingPage,
|
|
$this->loginPage,
|
|
$this->registrationPage,
|
|
$this->groupClassPage,
|
|
$this->familyPage,
|
|
);
|
|
}
|
|
|
|
public function testRegisterHooksBlockRegistrationAndAutoRedirect(): void
|
|
{
|
|
Actions\expectAdded('init')->once()->with([$this->registrar, 'registerBlocks']);
|
|
Actions\expectAdded('template_redirect')->once()->with([$this->registrar, 'maybeAutoRedirect']);
|
|
|
|
$this->registrar->register();
|
|
}
|
|
|
|
public function testRegisterBlocksRegistersAllBlocksWithAssets(): void
|
|
{
|
|
Functions\expect('wp_register_script')
|
|
->once()
|
|
->with(
|
|
BlockRegistrar::SCRIPT_HANDLE,
|
|
Mockery::pattern('~assets/js/blocks\.js$~'),
|
|
Mockery::type('array'),
|
|
USC_VERSION,
|
|
true
|
|
);
|
|
Functions\when('wp_style_is')->justReturn(false);
|
|
Functions\expect('wp_register_style')
|
|
->once()
|
|
->with(
|
|
BlockRegistrar::STYLE_HANDLE,
|
|
Mockery::pattern('~assets/css/frontend\.css$~'),
|
|
[],
|
|
USC_VERSION
|
|
);
|
|
|
|
$registered = [];
|
|
Functions\when('register_block_type')->alias(
|
|
static function (string $name, array $args) use (&$registered): bool {
|
|
$registered[$name] = $args;
|
|
return true;
|
|
}
|
|
);
|
|
|
|
$this->registrar->registerBlocks();
|
|
|
|
self::assertSame(
|
|
[
|
|
'us-scheduler/booking',
|
|
'us-scheduler/student-login',
|
|
'us-scheduler/student-register',
|
|
'us-scheduler/group-classes',
|
|
'us-scheduler/family',
|
|
],
|
|
array_keys($registered)
|
|
);
|
|
|
|
foreach ($registered as $args) {
|
|
self::assertSame(BlockRegistrar::SCRIPT_HANDLE, $args['editor_script']);
|
|
self::assertSame(BlockRegistrar::STYLE_HANDLE, $args['style']);
|
|
self::assertIsCallable($args['render_callback']);
|
|
}
|
|
|
|
// The link-target and auto-redirect options must be declared
|
|
// server-side or the block-renderer preview rejects them.
|
|
self::assertSame(
|
|
['loginPageId', 'autoRedirect', 'lessonTypeId', 'showTypeFilter', 'displayMode'],
|
|
array_keys($registered['us-scheduler/booking']['attributes'])
|
|
);
|
|
self::assertSame(
|
|
['bookingPageId', 'autoRedirect'],
|
|
array_keys($registered['us-scheduler/student-login']['attributes'])
|
|
);
|
|
self::assertSame(
|
|
['loginPageId', 'autoRedirect', 'inviteOnlyMessage'],
|
|
array_keys($registered['us-scheduler/student-register']['attributes'])
|
|
);
|
|
self::assertSame(
|
|
['offeringId'],
|
|
array_keys($registered['us-scheduler/group-classes']['attributes'])
|
|
);
|
|
}
|
|
|
|
public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void
|
|
{
|
|
Functions\when('wp_register_script')->justReturn(true);
|
|
Functions\when('wp_style_is')->justReturn(true);
|
|
Functions\expect('wp_register_style')->never();
|
|
Functions\when('register_block_type')->justReturn(true);
|
|
|
|
$this->registrar->registerBlocks();
|
|
}
|
|
|
|
public function testFrontEndRenderDelegatesToThePageObjectsPassingAttributes(): void
|
|
{
|
|
$this->registrar->preview = false;
|
|
|
|
$this->bookingPage->shouldReceive('render')
|
|
->once()->with(['loginPageId' => 5])->andReturn('booking-html');
|
|
$this->loginPage->shouldReceive('render')
|
|
->once()->with(['bookingPageId' => 9])->andReturn('login-html');
|
|
$this->registrationPage->shouldReceive('render')->once()->with([])->andReturn('register-html');
|
|
$this->groupClassPage->shouldReceive('render')->once()->with([])->andReturn('group-html');
|
|
|
|
self::assertSame('booking-html', $this->registrar->renderBooking(['loginPageId' => 5]));
|
|
self::assertSame('login-html', $this->registrar->renderLogin(['bookingPageId' => 9]));
|
|
self::assertSame('register-html', $this->registrar->renderRegistration());
|
|
self::assertSame('group-html', $this->registrar->renderGroupClasses());
|
|
}
|
|
|
|
public function testEditorPreviewRendersStaticMarkupWithoutTouchingThePages(): void
|
|
{
|
|
$this->registrar->preview = true;
|
|
|
|
Functions\when('wp_nonce_field')->justReturn('');
|
|
|
|
$this->bookingPage->shouldNotReceive('render');
|
|
$this->loginPage->shouldNotReceive('render');
|
|
$this->registrationPage->shouldNotReceive('render');
|
|
$this->groupClassPage->shouldNotReceive('render');
|
|
|
|
self::assertStringContainsString('us-booking-app', $this->registrar->renderBooking());
|
|
self::assertStringContainsString('us-login-form', $this->registrar->renderLogin());
|
|
self::assertStringContainsString('us-register-form', $this->registrar->renderRegistration());
|
|
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
|
|
}
|
|
|
|
public function testEditorPreviewOfAPinnedGroupClassOmitsTheDescription(): void
|
|
{
|
|
$this->registrar->preview = true;
|
|
$this->groupClassPage->shouldNotReceive('render');
|
|
|
|
$all = $this->registrar->renderGroupClasses();
|
|
$single = $this->registrar->renderGroupClasses(['offeringId' => 12]);
|
|
|
|
self::assertStringContainsString('A sample class shown so the page can be styled.', $all);
|
|
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $single);
|
|
self::assertStringContainsString('us-enrol-deadline', $single);
|
|
}
|
|
|
|
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
|
|
{
|
|
// REST_REQUEST is undefined in the test process, so the real
|
|
// registrar must take the front-end path.
|
|
$registrar = new BlockRegistrar(
|
|
$this->bookingPage,
|
|
$this->loginPage,
|
|
$this->registrationPage,
|
|
$this->groupClassPage,
|
|
$this->familyPage,
|
|
);
|
|
|
|
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
|
|
|
self::assertSame('live', $registrar->renderBooking());
|
|
}
|
|
|
|
/**
|
|
* Stubs the front-end request context for maybeAutoRedirect: a singular
|
|
* page whose content parses to the given blocks.
|
|
*
|
|
* @param list<array<string, mixed>> $parsedBlocks
|
|
*/
|
|
private function stubSingularRequest(int $postId, array $parsedBlocks, bool $loggedIn): void
|
|
{
|
|
$post = new \WP_Post();
|
|
$post->ID = $postId;
|
|
$post->post_content = 'serialized-block-content';
|
|
|
|
Functions\when('is_admin')->justReturn(false);
|
|
Functions\when('is_singular')->justReturn(true);
|
|
Functions\when('get_post')->justReturn($post);
|
|
Functions\when('is_user_logged_in')->justReturn($loggedIn);
|
|
Functions\when('has_block')->alias(
|
|
static function (string $blockName) use ($parsedBlocks): bool {
|
|
foreach ($parsedBlocks as $block) {
|
|
if (($block['blockName'] ?? null) === $blockName) {
|
|
return true;
|
|
}
|
|
foreach ((array) ($block['innerBlocks'] ?? []) as $inner) {
|
|
if (is_array($inner) && ($inner['blockName'] ?? null) === $blockName) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
);
|
|
Functions\when('parse_blocks')->justReturn($parsedBlocks);
|
|
}
|
|
|
|
public function testAutoRedirectSendsLoggedOutVisitorToTheLoginPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
10,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/booking',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->bookingPage->shouldReceive('loginUrl')->once()->with(7)->andReturn('https://example.com/login/');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame(['https://example.com/login/'], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testAutoRedirectFindsTheBookingBlockNestedInsideAnotherBlock(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
10,
|
|
[
|
|
[
|
|
'blockName' => 'core/group',
|
|
'attrs' => [],
|
|
'innerBlocks' => [
|
|
[
|
|
'blockName' => 'us-scheduler/booking',
|
|
'attrs' => ['autoRedirect' => true],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
// loginPageId omitted from the serialized block (defaults are not
|
|
// stored) — falls back to the WordPress login screen.
|
|
$this->bookingPage->shouldReceive('loginUrl')->once()->with(0)->andReturn('https://example.com/wp-login.php');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame(['https://example.com/wp-login.php'], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheBookingBlockDoesNotOptIn(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
10,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/booking',
|
|
'attrs' => ['loginPageId' => 7],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->bookingPage->shouldNotReceive('loginUrl');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheBookingBlockPointsAtItsOwnPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
7,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/booking',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->bookingPage->shouldNotReceive('loginUrl');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testAutoRedirectSendsLoggedInVisitorToTheBookingPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
20,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-login',
|
|
'attrs' => ['autoRedirect' => true, 'bookingPageId' => 9],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
true
|
|
);
|
|
|
|
$this->loginPage->shouldReceive('bookingUrl')->once()->with(9)->andReturn('https://example.com/book/');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame(['https://example.com/book/'], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheLoginBlockHasNoBookingPageChosen(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
20,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-login',
|
|
'attrs' => ['autoRedirect' => true],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
true
|
|
);
|
|
|
|
$this->loginPage->shouldReceive('bookingUrl')->once()->with(0)->andReturnNull();
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectForLoggedInVisitorOnTheBookingPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
10,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/booking',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
true
|
|
);
|
|
|
|
$this->bookingPage->shouldNotReceive('loginUrl');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testAutoRedirectSendsAFinishedRegistrationToTheChosenPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
30,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
|
$this->registrationPage->shouldReceive('continueUrl')
|
|
->once()->with(4)->andReturn('https://example.com/welcome/');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testAutoRedirectSendsAJustLoggedInInvitedStudentToTheChosenPage(): void
|
|
{
|
|
// The invited-student branch completes logged in, so the logged-in
|
|
// student-login branch must not get first claim on the request.
|
|
$this->stubSingularRequest(
|
|
30,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
true
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
|
$this->registrationPage->shouldReceive('continueUrl')
|
|
->once()->with(4)->andReturn('https://example.com/welcome/');
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheRegistrationIsNotFinished(): void
|
|
{
|
|
// e.g. the "check your email" step, or a validation error — the
|
|
// message has to be read, so the block never redirects past it.
|
|
$this->stubSingularRequest(
|
|
30,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheRegisterBlockDoesNotOptIn(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
30,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['loginPageId' => 4],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheRegisterBlockHasNoPageChosen(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
30,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['autoRedirect' => true],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
|
// No page chosen: there is no login-screen fallback to redirect to,
|
|
// so the student keeps the on-page confirmation instead.
|
|
$this->registrationPage->shouldReceive('continueUrl')->once()->with(0)->andReturnNull();
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectWhenTheRegisterBlockPointsAtItsOwnPage(): void
|
|
{
|
|
$this->stubSingularRequest(
|
|
4,
|
|
[
|
|
[
|
|
'blockName' => 'us-scheduler/student-register',
|
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
|
'innerBlocks' => [],
|
|
],
|
|
],
|
|
false
|
|
);
|
|
|
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
|
|
public function testNoRedirectOutsideSingularFrontEndRequests(): void
|
|
{
|
|
Functions\when('is_admin')->justReturn(false);
|
|
Functions\when('is_singular')->justReturn(false);
|
|
|
|
$this->registrar->maybeAutoRedirect();
|
|
|
|
self::assertSame([], $this->registrar->redirects);
|
|
}
|
|
}
|