CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
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]>
215 lines
7.7 KiB
PHP
215 lines
7.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Booking\BookingPage;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class BookingPageTest extends TestCase
|
|
{
|
|
private BookingPage $page;
|
|
private GuardianService&Mockery\MockInterface $guardians;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->guardians = Mockery::mock(GuardianService::class);
|
|
// Most cases are a single-student account: one bookable person, no picker.
|
|
$this->guardians->shouldReceive('bookableStudents')->andReturn(
|
|
[['id' => 3, 'name' => 'Ada', 'is_self' => true]]
|
|
)->byDefault();
|
|
|
|
$this->page = new BookingPage($this->guardians);
|
|
}
|
|
|
|
/**
|
|
* Renders the page as a logged-in, approved student — the path that
|
|
* includes the template and its data attributes.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function renderForStudent(array $atts): string
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
Functions\when('get_user_meta')->justReturn('');
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
|
Functions\when('wp_create_nonce')->justReturn('nonce123');
|
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
|
|
|
return $this->page->render($atts);
|
|
}
|
|
|
|
public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('get_permalink')->justReturn('https://example.com/book/');
|
|
Functions\when('wp_login_url')->alias(
|
|
static fn(string $redirect): string => 'https://example.com/wp-login.php?redirect_to=' . $redirect
|
|
);
|
|
|
|
$html = $this->page->render([]);
|
|
|
|
self::assertStringContainsString(
|
|
'href="https://example.com/wp-login.php?redirect_to=https://example.com/book/"',
|
|
$html
|
|
);
|
|
self::assertStringContainsString('log in to book a lesson', $html);
|
|
}
|
|
|
|
public function testLoggedOutVisitorIsLinkedToTheChosenLoginPage(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('get_permalink')->alias(
|
|
static fn(int $id = 0): string|false => 5 === $id ? 'https://example.com/login/' : false
|
|
);
|
|
Functions\expect('wp_login_url')->never();
|
|
|
|
$html = $this->page->render(['loginPageId' => 5]);
|
|
|
|
self::assertStringContainsString('href="https://example.com/login/"', $html);
|
|
}
|
|
|
|
public function testShortcodeStyleAttributeSelectsTheLoginPageToo(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('get_permalink')->alias(
|
|
static fn(int $id = 0): string|false => 5 === $id ? 'https://example.com/login/' : false
|
|
);
|
|
|
|
$html = $this->page->render(['login_page_id' => '5']);
|
|
|
|
self::assertStringContainsString('href="https://example.com/login/"', $html);
|
|
}
|
|
|
|
public function testDefaultEmbedShowsBothHalvesWithTheFilterAndNoPinnedType(): void
|
|
{
|
|
$html = $this->renderForStudent([]);
|
|
|
|
self::assertStringContainsString('id="us-booking-app"', $html);
|
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
|
self::assertStringNotContainsString('data-lesson-type', $html);
|
|
self::assertStringNotContainsString('data-type-filter', $html);
|
|
}
|
|
|
|
public function testBlockLessonTypeAttributePinsASingleType(): void
|
|
{
|
|
self::assertStringContainsString(
|
|
'data-lesson-type="12"',
|
|
$this->renderForStudent(['lessonTypeId' => 12])
|
|
);
|
|
}
|
|
|
|
public function testShortcodeLessonTypeAttributePinsASingleType(): void
|
|
{
|
|
self::assertStringContainsString(
|
|
'data-lesson-type="7"',
|
|
$this->renderForStudent(['lesson_type' => '7'])
|
|
);
|
|
}
|
|
|
|
public function testGarbageLessonTypeAttributeIsIgnored(): void
|
|
{
|
|
self::assertStringNotContainsString(
|
|
'data-lesson-type',
|
|
$this->renderForStudent(['lesson_type' => 'banana'])
|
|
);
|
|
}
|
|
|
|
public function testFilterCanBeTurnedOffByBlockAndShortcodeAlike(): void
|
|
{
|
|
self::assertStringContainsString(
|
|
'data-type-filter="0"',
|
|
$this->renderForStudent(['showTypeFilter' => false])
|
|
);
|
|
|
|
// "no" is truthy to PHP, so the shortcode wording is matched explicitly.
|
|
self::assertStringContainsString(
|
|
'data-type-filter="0"',
|
|
$this->renderForStudent(['show_filter' => 'no'])
|
|
);
|
|
|
|
self::assertStringNotContainsString(
|
|
'data-type-filter',
|
|
$this->renderForStudent(['show_filter' => 'yes'])
|
|
);
|
|
}
|
|
|
|
public function testBookingOnlyEmbedLeavesOutTheUpcomingLessons(): void
|
|
{
|
|
$html = $this->renderForStudent(['displayMode' => 'booking']);
|
|
|
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
|
self::assertStringNotContainsString('us-my-lessons', $html);
|
|
}
|
|
|
|
public function testUpcomingOnlyEmbedLeavesOutTheBookingCalendar(): void
|
|
{
|
|
$html = $this->renderForStudent(['show' => 'upcoming']);
|
|
|
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
|
self::assertStringNotContainsString('us-slot-list', $html);
|
|
self::assertStringNotContainsString('us-booking-confirmation', $html);
|
|
}
|
|
|
|
public function testUnknownDisplayModeShowsTheWholePage(): void
|
|
{
|
|
$html = $this->renderForStudent(['displayMode' => 'sideways']);
|
|
|
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
|
}
|
|
|
|
public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void
|
|
{
|
|
// The chosen page was deleted: get_permalink() returns false for it
|
|
// and for the current (test) context alike.
|
|
Functions\when('get_permalink')->justReturn(false);
|
|
Functions\when('wp_login_url')->alias(
|
|
static fn(string $redirect): string => '' === $redirect
|
|
? 'https://example.com/wp-login.php'
|
|
: 'https://example.com/wp-login.php?redirect_to=' . $redirect
|
|
);
|
|
|
|
self::assertSame('https://example.com/wp-login.php', $this->page->loginUrl(5));
|
|
}
|
|
|
|
/**
|
|
* A single-student account gets a one-entry list, which the script renders
|
|
* as no picker at all.
|
|
*/
|
|
public function testStudentListIsEmbeddedForTheScript(): void
|
|
{
|
|
$html = $this->renderForStudent([]);
|
|
|
|
self::assertStringContainsString('data-students=', $html);
|
|
self::assertStringContainsString('"is_self":true', $html);
|
|
}
|
|
|
|
/**
|
|
* Children lead the embedded list, so the picker's default selection is a
|
|
* child rather than the parent.
|
|
*/
|
|
public function testGuardianListLeadsWithChildren(): void
|
|
{
|
|
$this->guardians->shouldReceive('bookableStudents')->with(3)->andReturn([
|
|
['id' => 42, 'name' => 'Ada', 'is_self' => false],
|
|
['id' => 3, 'name' => 'Grace', 'is_self' => true],
|
|
]);
|
|
|
|
$html = $this->renderForStudent([]);
|
|
|
|
$students = json_decode(html_entity_decode((string) preg_replace('/.*data-students="([^"]*)".*/s', '$1', $html)), true);
|
|
|
|
self::assertSame([42, 3], array_column((array) $students, 'id'));
|
|
self::assertFalse($students[0]['is_self']);
|
|
}
|
|
}
|