Merge pull request 'Show booked lesson info on upcoming lists and add admin booking detail' (#104) from feature/lesson-booking-detail into main
CI / Tests (PHP 8.2) (push) Successful in 39s
CI / Coding Standards (push) Successful in 2m52s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m46s

Reviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
2026-07-24 14:12:33 +00:00
12 changed files with 569 additions and 21 deletions
@@ -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']);
}
}
+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();
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Mockery;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LessonDetailTest extends TestCase
{
private AnswerRepository&Mockery\MockInterface $answers;
private QuestionRepository&Mockery\MockInterface $questions;
private AcceptanceRepository&Mockery\MockInterface $acceptances;
private PolicyRepository&Mockery\MockInterface $policies;
private PolicyVersionRepository&Mockery\MockInterface $versions;
private LessonDetail $detail;
protected function setUp(): void
{
parent::setUp();
$this->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)
);
}
}