CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking reserves a series of lessons, but the student answers the intake and ticks the policy boxes once — so BookingEndpoint records both against the anchor lesson alone. The admin detail view looked them up by whichever lesson id was being viewed, so every occurrence after the first showed no answers and no acceptances at all. LessonDetail now takes the Lesson rather than a bare id and resolves the registration to `series_id ?? id`, so each occurrence reads the anchor's records. This is the same seam PaymentService already uses to find a series lesson's payment on the anchor. Nothing was ever missing from the database, so existing bookings read correctly with no migration and no schema change. Closes #167 Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
11 KiB
PHP
285 lines
11 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use Mockery;
|
||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||
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;
|
||
|
||
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
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->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(LessonDetail::class);
|
||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||
|
||
$_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);
|
||
}
|
||
|
||
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'],
|
||
]);
|
||
$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'],
|
||
]);
|
||
|
||
// 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();
|
||
$this->controller->renderAdminDashboard();
|
||
|
||
return (string) ob_get_clean();
|
||
}
|
||
}
|