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)))}
+ Cancel
+
+
`;
+ }
+
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)))}
- Cancel
-
-
- `).join('')}
+ ${visible.map(lessonRowHtml).join('')}
+ ${hidden.length ? `
+
${hidden.map(lessonRowHtml).join('')}
+
Show all ${upcoming.length} lessons
+ ` : ''}
`;
+ 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
+ */
+?>
+
+
+
+
«
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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)
+ );
+ }
+}