Show booked lesson info on upcoming lists and add admin booking detail
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped

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 <[email protected]>
This commit is contained in:
2026-07-24 11:07:06 -03:00
co-authored by Claude Opus 4.8
parent 991ed2f5ad
commit 32619a1b75
12 changed files with 569 additions and 21 deletions
+97 -1
View File
@@ -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();