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]>
283 lines
11 KiB
PHP
283 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);
|
||
$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();
|
||
$this->controller->renderAdminDashboard();
|
||
|
||
return (string) ob_get_clean();
|
||
}
|
||
}
|