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 AM–10: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 PM–Jul 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 PM–3: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
; 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(); } }