Files
unsupervised-scheduler/tests/Unit/Booking/LessonControllerTest.php
T
thatguygriffandClaude Opus 5 8c21a3fa9d
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio book lessons and record intake collected elsewhere
Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
2026-08-24 14:06:16 -03:00

519 lines
21 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);
}
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));
}
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();
}
}