Files
unsupervised-scheduler/tests/Unit/Booking/LessonControllerTest.php
T
thatguygriffandClaude Opus 5 5ce42f0003
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio register the students who cannot register themselves
The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.

Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.

Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.

Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.

Closes #185

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
2026-08-24 18:42:59 -03:00

577 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\Booking\AdminBooking;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LessonControllerTest extends TestCase
{
private BookingRepository&Mockery\MockInterface $bookings;
private PaymentRepository&Mockery\MockInterface $payments;
private AvailabilityRepository&Mockery\MockInterface $availability;
private OfferingRepository&Mockery\MockInterface $offerings;
private IntakeAudit&Mockery\MockInterface $detail;
private AdminBooking&Mockery\MockInterface $adminBooking;
private IntakeRecording&Mockery\MockInterface $intake;
private LessonController $controller;
protected function setUp(): void
{
parent::setUp();
$this->bookings = Mockery::mock(BookingRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class);
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->detail = Mockery::mock(IntakeAudit::class);
$this->adminBooking = Mockery::mock(AdminBooking::class);
// The book-for-a-student panel has its own tests; here it is an empty form.
$this->adminBooking->shouldReceive('formData')
->andReturn(['students' => [], 'offerings' => [], 'slots' => []])->byDefault();
$this->intake = Mockery::mock(IntakeRecording::class);
// Most lessons here were booked by the student, so nothing is recordable;
// the intake tests set up their own staff-booked lesson.
$this->intake->shouldReceive('pending')
->andReturn(['questions' => [], 'policies' => []])->byDefault();
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail, $this->adminBooking, $this->intake);
$_POST = [];
$_GET = [];
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_userdata')->justReturn(false);
Functions\when('mysql2date')->alias(
static fn (string $format, string $date) => date($format, (int) strtotime($date))
);
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('get_option')->justReturn(1);
Functions\when('current_time')->justReturn('2026-07-06');
Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value);
Functions\when('wp_nonce_field')->justReturn('');
}
protected function tearDown(): void
{
// The form-post tests fill $_POST; left behind it makes every later test
// in the suite look like a form submission.
$_POST = [];
$_GET = [];
parent::tearDown();
}
public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void
{
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$html = $this->render();
self::assertStringContainsString('Jul 6, 2026 9:00 AM10:00 AM', $html);
self::assertStringContainsString('Date/Time', $html);
self::assertStringNotContainsString('Slot ID', $html);
}
public function testSlotCrossingMidnightRepeatsTheDateOnTheEndTime(): void
{
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 11, studentId: 5, instructorId: 3, id: 2);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 23:00:00',
endDt: '2026-07-07 00:30:00',
id: 11
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(11)->andReturn($slot);
$html = $this->render();
self::assertStringContainsString('Jul 6, 2026 11:00 PMJul 7, 2026 12:30 AM', $html);
}
public function testMissingSlotRendersDash(): void
{
$lesson = new Lesson(slotId: 99, studentId: 5, instructorId: 3, id: 3);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(99)->andReturn(null);
$html = $this->render();
self::assertStringContainsString('—', $html);
}
public function testInstructorLessonsShowSlotDateTime(): void
{
$_GET['usc_view'] = 'list';
Functions\when('get_current_user_id')->justReturn(3);
$lesson = new Lesson(slotId: 12, studentId: 5, instructorId: 3, id: 4);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-08-01 14:00:00',
endDt: '2026-08-01 15:00:00',
id: 12
);
$this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(3)->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(12)->andReturn($slot);
ob_start();
$this->controller->renderInstructorLessons();
$html = (string) ob_get_clean();
self::assertStringContainsString('Aug 1, 2026 2:00 PM3:00 PM', $html);
}
public function testDefaultsToWeekViewWithLessonInItsDay(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-08 09:00:00',
endDt: '2026-07-08 10:00:00',
id: 10
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$html = $this->render();
// Week of Monday 2026-07-06 (start_of_week = 1, today = 2026-07-06).
self::assertStringContainsString('Week of Jul 6, 2026', $html);
self::assertStringContainsString('Wed Jul 8', $html);
self::assertStringContainsString('9:00 AM', $html);
// The list table is not rendered in week view.
self::assertStringNotContainsString('Date/Time', $html);
}
public function testWeekViewHonoursRequestedWeek(): void
{
$_GET['usc_week'] = '2026-08-01';
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
// 2026-08-01 is a Saturday; its Monday-start week begins 2026-07-27.
self::assertStringContainsString('Week of Jul 27, 2026', $html);
}
public function testLessonOutsideDisplayedWeekIsNotShown(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-09-01 09:00:00',
endDt: '2026-09-01 10:00:00',
id: 10
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$html = $this->render();
self::assertStringNotContainsString('9:00 AM', $html);
}
public function testListViewShowsBookedOfferingName(): void
{
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$offering = new \Unsupervised\Schedular\Offering\Offering(
instructorId: 3,
kind: 'private_lesson',
title: 'Piano Lesson',
durationMinutes: 60,
id: 8
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
$html = $this->render();
self::assertStringContainsString('Piano Lesson', $html);
self::assertStringContainsString('lesson_id=1', $html);
}
public function testLessonIdRoutesToDetailWithAnswersAndPolicies(): void
{
$_GET['lesson_id'] = '1';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$offering = new \Unsupervised\Schedular\Offering\Offering(
instructorId: 3,
kind: 'private_lesson',
title: 'Piano Lesson',
durationMinutes: 60,
id: 8
);
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
// The lesson itself is handed over, so the presenter can follow a series
// occurrence back to the anchor its answers and acceptances hang off.
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
]);
$this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', 'source' => 'Given online when booking'],
]);
// The list of lessons must never be queried when routing to a detail view.
$this->bookings->shouldNotReceive('findAllUpcoming');
$html = $this->render();
self::assertStringContainsString('Lesson details', $html);
self::assertStringContainsString('Piano Lesson', $html);
self::assertStringContainsString('Skill level', $html);
self::assertStringContainsString('Beginner', $html);
self::assertStringContainsString('Cancellation', $html);
}
public function testInstructorCannotOpenAnotherInstructorsLessonDetail(): void
{
$_GET['lesson_id'] = '1';
Functions\when('get_current_user_id')->justReturn(99);
// The lesson belongs to instructor 3, not the current user (99).
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->detail->shouldNotReceive('answers');
$this->detail->shouldNotReceive('acceptances');
ob_start();
$this->controller->renderInstructorLessons();
$html = (string) ob_get_clean();
self::assertStringContainsString('could not be found', $html);
self::assertStringNotContainsString('Skill level', $html);
}
public function testTheBookForAStudentPanelOffersTheOpenTimesAndStudents(): void
{
$this->adminBooking->shouldReceive('formData')->once()->with(0)->andReturn([
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
]);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Book a lesson for a student', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $html);
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
}
/**
* A refusal used to clear all five fields, so one mistake meant retyping the
* whole form — and the panel is only ever reopened *because* something was
* refused.
*/
public function testARefusedBookingComesBackWithEveryFieldStillFilledIn(): void
{
$this->postBooking();
$this->offerPanel();
$this->adminBooking->shouldReceive('book')->once()->andReturn(
new \WP_Error('slot_taken', 'That time has already been booked.')
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('That time has already been booked.', $html);
// Attribute spacing in the template is not what is under test here.
$tags = (string) preg_replace('/\s+/', ' ', $html);
// The panel is reopened, showing the student, time and lesson type as posted.
self::assertStringContainsString(' open>', $html);
self::assertStringContainsString('value="42" selected=\'selected\'', $tags);
self::assertStringContainsString('value="7" selected=\'selected\'', $tags);
self::assertStringContainsString('value="3" selected=\'selected\'', $tags);
// Both ticks and the note survive too.
self::assertSame(2, substr_count($html, "checked='checked'"));
self::assertStringContainsString('value="Make-up lesson"', $html);
}
public function testASuccessfulBookingLeavesAnEmptyFormForTheNextOne(): void
{
$this->postBooking();
$this->offerPanel();
$this->adminBooking->shouldReceive('book')->once()->andReturn('Booked.');
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Booked.', $html);
// Nothing carried over, or the next booking silently inherits this one's.
self::assertStringNotContainsString("selected='selected'", $html);
self::assertStringNotContainsString("checked='checked'", $html);
self::assertStringContainsString('value=""', $html);
}
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
{
$this->postBooking();
// Scope 0: the studio Scheduler may book any instructor's open time.
$this->adminBooking->shouldReceive('book')
->once()
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 0)
->andReturn('Booked Ada Lovelace into 30 min piano.');
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Booked Ada Lovelace into 30 min piano.', $html);
self::assertStringContainsString('notice-success', $html);
self::assertStringNotContainsString(' open>', $html);
}
public function testAnInstructorBooksOnlyAgainstTheirOwnTimes(): void
{
$this->postBooking();
Functions\when('get_current_user_id')->justReturn(9);
// Scope 9: My Lessons must not reach another instructor's schedule.
$this->adminBooking->shouldReceive('book')
->once()
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 9)
->andReturn('Booked.');
$this->adminBooking->shouldReceive('formData')->once()->with(9)->andReturn(
['students' => [], 'offerings' => [], 'slots' => []]
);
$this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(9)->andReturn([]);
ob_start();
$this->controller->renderInstructorLessons();
$html = (string) ob_get_clean();
self::assertStringContainsString('Booked.', $html);
}
public function testARefusedBookingShowsWhyAndReopensTheForm(): void
{
$this->postBooking();
$this->adminBooking->shouldReceive('book')->once()
->andReturn(new \WP_Error('slot_taken', 'That time has already been booked.'));
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('That time has already been booked.', $html);
self::assertStringContainsString('notice-error', $html);
// The panel is a collapsed <details>; an error opens it so the message is
// not hidden behind the summary.
self::assertStringContainsString(' open>', $html);
}
/** Fill $_POST as the book-for-a-student form does. */
private function postBooking(): void
{
$_POST = [
'usc_action' => 'book_for_student',
'student_id' => '42',
'slot_id' => '7',
'offering_id' => '3',
'recurrence_weekly' => '1',
'no_charge' => '1',
'notes' => 'Make-up lesson',
];
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
}
/** The panel with one of each choice, so a re-selected value has somewhere to land. */
private function offerPanel(): void
{
$this->adminBooking->shouldReceive('formData')->once()->andReturn([
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
]);
}
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
{
$_GET['lesson_id'] = '1';
Functions\when('wp_nonce_field')->justReturn('');
// booked_by 7: the studio booked this one, so its intake can be recorded.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
$this->expectDetail($lesson);
$this->intake->shouldReceive('pending')->once()->with($lesson)->andReturn([
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => true]],
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
]);
$html = $this->render();
self::assertStringContainsString('Record intake collected elsewhere', $html);
self::assertStringContainsString('Anything we should know?', $html);
self::assertStringContainsString('Cancellation', $html);
self::assertStringContainsString('How were these collected?', $html);
self::assertStringContainsString('On a signed paper form', $html);
}
public function testALessonTheStudentBookedOffersNoRecordingForm(): void
{
$_GET['lesson_id'] = '1';
// booked_by 0: the student booked it and gave their own answers.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$this->expectDetail($lesson);
// Not even asked what is outstanding — the form is not on offer at all.
$this->intake->shouldNotReceive('pending');
self::assertStringNotContainsString('Record intake collected elsewhere', $this->render());
}
public function testSubmittedIntakeIsRecordedAndReported(): void
{
$_GET['lesson_id'] = '1';
$_POST = [
'usc_action' => 'record_intake',
'answers' => ['9' => 'Nut allergy'],
'accepted_policy_version_ids' => ['6'],
'collected_via' => 'paper',
'collected_note' => 'Filed in the studio binder',
];
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('sanitize_textarea_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(7);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
$this->expectDetail($lesson);
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
$this->intake->shouldReceive('record')
->once()
->with($lesson, [9 => 'Nut allergy'], [6], 'paper', 'Filed in the studio binder', 7)
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: On a signed paper form');
$html = $this->render();
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
self::assertStringContainsString('notice-success', $html);
// Nothing left outstanding, so the form gives way to a plain statement.
self::assertStringContainsString('Everything has been recorded for this booking.', $html);
}
public function testARefusedRecordingSaysWhy(): void
{
$_GET['lesson_id'] = '1';
$_POST = [
'usc_action' => 'record_intake',
'answers' => ['9' => 'Nut allergy'],
'collected_via' => 'other',
];
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('sanitize_textarea_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(7);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
$this->expectDetail($lesson);
$this->intake->shouldReceive('pending')->andReturn([
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
'policies' => [],
]);
$this->intake->shouldReceive('record')->once()
->andReturn(new \WP_Error('collection_note_required', 'Say how these were collected.'));
$html = $this->render();
self::assertStringContainsString('Say how these were collected.', $html);
self::assertStringContainsString('notice-error', $html);
}
/** The lookups the detail view makes for one lesson, with an empty audit trail. */
private function expectDetail(Lesson $lesson): void
{
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
$this->detail->shouldReceive('answers')->with($lesson)->andReturn([]);
$this->detail->shouldReceive('acceptances')->with($lesson)->andReturn([]);
}
private function render(): string
{
ob_start();
$this->controller->renderAdminDashboard();
return (string) ob_get_clean();
}
}