From 32619a1b754a4eaf8dc86bb7f32e709c331d54db Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 24 Jul 2026 11:07:06 -0300 Subject: [PATCH] Show booked lesson info on upcoming lists and add admin booking detail Front end: the student "upcoming lessons" panel now shows each booked offering's name and length next to the time, and renders only the soonest five lessons with a "Show all" reveal. GET /bookings returns offering_title and duration_minutes so the list needs no extra request. Admin: the Scheduler and My Lessons week/list views now show the booked offering, and each lesson links to a detail view showing the policy versions the student accepted (with acceptance time and IP) and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any. composer test / composer lint / composer cs all pass. Co-Authored-By: Claude Opus 4.8 --- assets/css/frontend.css | 32 ++++++ assets/js/booking.js | 47 ++++++-- docs/features/lesson-booking.md | 13 ++- src/AdminMenu.php | 3 +- src/Booking/BookingEndpoint.php | 20 +++- src/Booking/LessonController.php | 46 ++++++++ src/Booking/LessonDetail.php | 73 ++++++++++++ templates/admin/lesson-detail.php | 121 ++++++++++++++++++++ templates/admin/lessons.php | 24 +++- tests/Unit/Booking/BookingEndpointTest.php | 18 +++ tests/Unit/Booking/LessonControllerTest.php | 98 +++++++++++++++- tests/Unit/Booking/LessonDetailTest.php | 95 +++++++++++++++ 12 files changed, 569 insertions(+), 21 deletions(-) create mode 100644 src/Booking/LessonDetail.php create mode 100644 templates/admin/lesson-detail.php create mode 100644 tests/Unit/Booking/LessonDetailTest.php diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 132c17e..ecbcced 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -46,6 +46,26 @@ display: flex; justify-content: space-between; align-items: center; + gap: 12px; +} + +.us-my-lesson-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.us-my-lesson-title { + font-size: 1.05em; +} + +.us-my-lesson-duration { + font-weight: normal; + color: #666; +} + +.us-my-lesson-when { + color: #555; } .us-my-lesson-actions { @@ -54,6 +74,18 @@ align-items: center; } +.us-show-all-lessons { + background: transparent; + border: 1px solid #ccc; + border-radius: 4px; + padding: 6px 14px; + cursor: pointer; +} + +.us-show-all-lessons:hover { + border-color: #888; +} + .us-cancel-lesson { background: transparent; border: 1px solid #ccc; diff --git a/assets/js/booking.js b/assets/js/booking.js index 2747d5f..d19752f 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -388,6 +388,25 @@ return status.charAt(0).toUpperCase() + status.slice(1); } + // How many upcoming lessons to show before the "Show all" reveal. + const INITIAL_LESSON_COUNT = 5; + + function lessonRowHtml(l) { + const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson'; + const duration = l.duration_minutes ? ` (${escHtml(String(l.duration_minutes))} min)` : ''; + return ` +
+ + ${title}${duration} + ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))} + + + ${escHtml(lessonStatusLabel(String(l.status)))} + + +
`; + } + function renderMyLessons(lessons) { const upcoming = lessons.filter((l) => l.start_dt); if (!upcoming.length) { @@ -395,20 +414,30 @@ return; } + // Show only the soonest few by default; the rest sit hidden behind a + // reveal so a busy student's list stays short. + const visible = upcoming.slice(0, INITIAL_LESSON_COUNT); + const hidden = upcoming.slice(INITIAL_LESSON_COUNT); + myLessons.innerHTML = `

Your upcoming lessons

- ${upcoming.map((l) => ` -
- ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))} - - ${escHtml(lessonStatusLabel(String(l.status)))} - - -
- `).join('')} + ${visible.map(lessonRowHtml).join('')} + ${hidden.length ? ` + + + ` : ''}
`; + const moreBox = myLessons.querySelector('.us-my-lessons-more'); + const showAll = myLessons.querySelector('.us-show-all-lessons'); + if (showAll && moreBox) { + showAll.addEventListener('click', () => { + moreBox.hidden = false; + showAll.remove(); + }); + } + myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => { btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId))); }); diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 35da9cb..9785d25 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -29,7 +29,7 @@ Students register for a private lesson by choosing an offering, picking a time ( 7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately. 8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed. 9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. -10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button. +10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request. ## Cancellation Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). @@ -85,7 +85,12 @@ kind `group_class`; see `group-classes.md`. Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week` query params, same pattern as the availability page, bucketed via `Availability\WeekCalendar`), with the original table available as the **List** -view — the list is where the per-lesson HST and e-transfer edit forms live. +view — the list is where the per-lesson HST and e-transfer edit forms live. Both +views show the booked offering's name, and each lesson links through (`?lesson_id=`) +to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the +offering, time, status, notes, the policy versions the student accepted (with +acceptance time and IP), and their intake-question answers. On **My Lessons** an +instructor may only open their own lessons; the studio **Scheduler** may open any. ## Frontend Shortcodes - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability @@ -96,6 +101,7 @@ view — the list is where the per-lesson HST and e-transfer edit forms live. - Model: `Unsupervised\Schedular\Booking\Lesson` - Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment - Admin controller: `Unsupervised\Schedular\Booking\LessonController` +- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` @@ -109,3 +115,6 @@ view — the list is where the per-lesson HST and e-transfer edit forms live. ## Tests - `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/LessonTest.php` +- `tests/Unit/Booking/LessonControllerTest.php` +- `tests/Unit/Booking/LessonDetailTest.php` +- `tests/Unit/Booking/BookingEndpointTest.php` diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 3945351..697ed01 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -17,6 +17,7 @@ use Unsupervised\Schedular\Auth\StudentController; use Unsupervised\Schedular\Auth\StudentHistory; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\LessonController; +use Unsupervised\Schedular\Booking\LessonDetail; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassController; @@ -57,7 +58,7 @@ class AdminMenu { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); - $this->lessonController = new LessonController( $bookings, $payments, $availability ); + $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->questionController = new QuestionController( $questions, $offerings ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index bce4219..35832ef 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -123,17 +123,27 @@ class BookingEndpoint { } /** - * A lesson's array form plus its slot's start/end times, so front-end lists - * can show when the session happens without a second request. + * A lesson's array form plus its slot's start/end times and the booked + * offering's name, so front-end lists can show what the session is and when + * it happens without a second request. * * @return array */ private function lessonWithTimes( Lesson $lesson ): array { - $slot = $this->availability->findById( $lesson->slotId ); + $slot = $this->availability->findById( $lesson->slotId ); + $offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null; + + // Prefer the offering's own length; fall back to the slot's when the + // offering has none (a generic, duration-less type). + $duration = null !== $offering && null !== $offering->durationMinutes + ? $offering->durationMinutes + : $slot?->durationMinutes; return $lesson->toArray() + [ - 'start_dt' => $slot?->startDt, - 'end_dt' => $slot?->endDt, + 'start_dt' => $slot?->startDt, + 'end_dt' => $slot?->endDt, + 'offering_title' => $offering?->title, + 'duration_minutes' => $duration, ]; } diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index 8a96e64..52f7a38 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\WeekCalendar; +use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Val; @@ -17,6 +18,8 @@ class LessonController { private BookingRepository $repository, private PaymentRepository $payments, private AvailabilityRepository $availability, + private OfferingRepository $offerings, + private LessonDetail $detail, ) {} public function renderAdminDashboard(): void { @@ -24,6 +27,10 @@ class LessonController { wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) ); } + if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) { + return; + } + $this->handleEtransferUpdate( false ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); @@ -36,6 +43,10 @@ class LessonController { wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) ); } + if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) { + return; + } + $this->handleEtransferUpdate( true ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); @@ -43,6 +54,36 @@ class LessonController { $this->renderLessonsPage( $rows, 'us-my-lessons' ); } + /** + * When the request targets a single lesson (`?lesson_id=`), render its detail + * view and report that the page has been handled. Instructors may only open + * their own lessons; the studio dashboard ($onlyOwn = false) may open any. + */ + private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector. + $lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) ); + if ( $lessonId <= 0 ) { + return false; + } + + $lesson = $this->repository->findById( $lessonId ); + $backUrl = admin_url( 'admin.php?page=' . $pageSlug ); + + if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) { + $row = null; + $answers = []; + $accepts = []; + } else { + $row = $this->row( $lesson ); + $answers = $this->detail->answers( $lessonId ); + $accepts = $this->detail->acceptances( $lessonId ); + } + + include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php'; + + return true; + } + /** * Render the lessons template with its calendar view state: week (default) * or list, plus which week the week view shows. @@ -111,10 +152,15 @@ class LessonController { $instructor = get_userdata( $lesson->instructorId ); $payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null; $slot = $this->availability->findById( $lesson->slotId ); + $offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null; return [ + 'lesson_id' => (int) $lesson->id, 'student' => $student ? $student->display_name : (string) $lesson->studentId, 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, + 'offering' => $offering ? $offering->title : '—', + 'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0, + 'recurrence' => $lesson->recurrence, 'time' => $slot ? $this->formatSlotTime( $slot ) : '—', 'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '', 'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—', diff --git a/src/Booking/LessonDetail.php b/src/Booking/LessonDetail.php new file mode 100644 index 0000000..bbff5f6 --- /dev/null +++ b/src/Booking/LessonDetail.php @@ -0,0 +1,73 @@ + + */ + public function answers( int $lessonId ): array { + return array_map( + function ( Answer $answer ): array { + $question = $this->questions->findById( $answer->questionId ); + $value = $answer->answerValue ?? ''; + + return [ + 'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ), + 'answer' => '' === $value ? '—' : $value, + ]; + }, + $this->answers->findByRegistration( Answer::REG_LESSON, $lessonId ) + ); + } + + /** + * The policy versions the student accepted when booking this lesson, with the + * captured acceptance time and IP for the audit trail. + * + * @return list + */ + public function acceptances( int $lessonId ): array { + return array_map( + function ( PolicyAcceptance $acceptance ): array { + $version = $this->versions->findById( $acceptance->policyVersionId ); + $policy = $version ? $this->policies->findById( $version->policyId ) : null; + + return [ + 'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ), + 'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—', + 'accepted_at' => $acceptance->acceptedAt ?? '', + 'ip' => $acceptance->ipAddress ?? '', + ]; + }, + $this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId ) + ); + } +} diff --git a/templates/admin/lesson-detail.php b/templates/admin/lesson-detail.php new file mode 100644 index 0000000..e382a6d --- /dev/null +++ b/templates/admin/lesson-detail.php @@ -0,0 +1,121 @@ + $answers + * @var list $accepts + * @var string $backUrl + */ +?> +
+

+ +

«

+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + 0) : ?> + + + + + + + + + + + + +
+ + 0) : ?> + + +
+ + + () + +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + +
+ + +

+ +

+ + + + + + + + + + + + + + + + +
+ + +
diff --git a/templates/admin/lessons.php b/templates/admin/lessons.php index 9879211..d2b13c9 100644 --- a/templates/admin/lessons.php +++ b/templates/admin/lessons.php @@ -6,10 +6,10 @@ if (! defined('ABSPATH')) { } /** - * @var list $rows + * @var list $rows * @var 'list'|'week' $view * @var string $weekStart - * @var list}> $weekDays + * @var list}> $weekDays * @var string $prevWeek * @var string $nextWeek * @var string $baseUrl @@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {



- +
+
+

@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) { + + @@ -87,6 +91,17 @@ if (! defined('ABSPATH')) { + + + 0) : ?> + + + + + @@ -118,6 +133,9 @@ if (! defined('ABSPATH')) { + + + diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index 6d69657..c053c53 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -544,4 +544,22 @@ class BookingEndpointTest extends TestCase self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']); self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']); } + + public function testMyLessonsIncludesBookedOfferingName(): void + { + Functions\when('current_user_can')->justReturn(false); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, status: Lesson::STATUS_PENDING, id: 77); + $this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]); + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 8)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano Lesson', durationMinutes: 60, id: 8) + ); + + $result = $this->endpoint->myLessons(new \WP_REST_Request([])); + + $data = $result->get_data(); + self::assertSame('Piano Lesson', $data[0]['offering_title']); + self::assertSame(60, $data[0]['duration_minutes']); + } } diff --git a/tests/Unit/Booking/LessonControllerTest.php b/tests/Unit/Booking/LessonControllerTest.php index a290953..6c048da 100644 --- a/tests/Unit/Booking/LessonControllerTest.php +++ b/tests/Unit/Booking/LessonControllerTest.php @@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Booking\LessonController; +use Unsupervised\Schedular\Booking\LessonDetail; +use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Tests\Unit\TestCase; @@ -18,6 +20,8 @@ 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 LessonDetail&Mockery\MockInterface $detail; private LessonController $controller; protected function setUp(): void @@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase $this->bookings = Mockery::mock(BookingRepository::class); $this->payments = Mockery::mock(PaymentRepository::class); $this->availability = Mockery::mock(AvailabilityRepository::class); - $this->controller = new LessonController($this->bookings, $this->payments, $this->availability); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->detail = Mockery::mock(LessonDetail::class); + $this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail); $_POST = []; $_GET = []; @@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase 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); + $this->detail->shouldReceive('answers')->once()->with(1)->andReturn([ + ['question' => 'Skill level', 'answer' => 'Beginner'], + ]); + $this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([ + ['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'], + ]); + + // 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); + } + private function render(): string { ob_start(); diff --git a/tests/Unit/Booking/LessonDetailTest.php b/tests/Unit/Booking/LessonDetailTest.php new file mode 100644 index 0000000..ad7fe09 --- /dev/null +++ b/tests/Unit/Booking/LessonDetailTest.php @@ -0,0 +1,95 @@ +answers = Mockery::mock(AnswerRepository::class); + $this->questions = Mockery::mock(QuestionRepository::class); + $this->acceptances = Mockery::mock(AcceptanceRepository::class); + $this->policies = Mockery::mock(PolicyRepository::class); + $this->versions = Mockery::mock(PolicyVersionRepository::class); + + $this->detail = new LessonDetail( + $this->answers, + $this->questions, + $this->acceptances, + $this->policies, + $this->versions + ); + } + + public function testAnswersPairEachAnswerWithItsQuestionLabel(): void + { + $this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([ + new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'), + new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null), + ]); + + $this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2)); + $this->questions->shouldReceive('findById')->with(9)->andReturn(null); + + self::assertSame( + [ + ['question' => 'Skill level', 'answer' => 'Beginner'], + ['question' => '#9', 'answer' => '—'], + ], + $this->detail->answers(7) + ); + } + + public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void + { + $this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([ + new PolicyAcceptance( + policyVersionId: 4, + studentId: 5, + registrationType: PolicyAcceptance::REG_LESSON, + registrationId: 7, + ipAddress: '1.2.3.4', + acceptedAt: '2026-07-01 10:00:00' + ), + ]); + + $this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4)); + $this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3)); + + self::assertSame( + [ + [ + 'policy' => 'Cancellation', + 'version' => 'v2', + 'accepted_at' => '2026-07-01 10:00:00', + 'ip' => '1.2.3.4', + ], + ], + $this->detail->acceptances(7) + ); + } +}