Files
unsupervised-scheduler/tests/Unit/Booking/AdminBookingTest.php
T
thatguygriffandClaude Opus 5 8c21a3fa9d
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio book lessons and record intake collected elsewhere
Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
2026-08-24 14:06:16 -03:00

316 lines
14 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\AdminBooking;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AdminBookingTest extends TestCase
{
private AvailabilityRepository&Mockery\MockInterface $availability;
private BookingRepository&Mockery\MockInterface $bookings;
private OfferingRepository&Mockery\MockInterface $offerings;
private PaymentService&Mockery\MockInterface $payments;
private GuardianService&Mockery\MockInterface $guardians;
private AdminBooking $admin;
protected function setUp(): void
{
parent::setUp();
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
Functions\when('mysql2date')->alias(
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
);
Functions\when('get_userdata')->justReturn(false);
Functions\when('get_users')->justReturn([]);
// Everyone offered in the picker can book; the guard is exercised on its own.
Functions\when('user_can')->justReturn(true);
// The staff member doing the booking; stamped on the lesson as booked_by.
Functions\when('get_current_user_id')->justReturn(3);
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
// The real booker over mocked repositories: an admin booking must go
// through exactly the machinery a student's own booking does.
$this->admin = new AdminBooking(
$this->availability,
$this->offerings,
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians)
);
}
public function testBooksASingleLessonAndRaisesAPendingPayment(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Lesson $l): bool => 7 === $l->slotId
&& 42 === $l->studentId
&& 9 === $l->instructorId
&& 3 === $l->offeringId
&& Lesson::RECURRENCE_SINGLE === $l->recurrence
&& 'Booked by phone' === $l->notes
// Stamped with who booked it, which is what later lets the studio
// record the intake it never had a chance to collect.
&& 3 === $l->bookedBy
))->andReturn(100);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42)
->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone');
self::assertIsString($notice);
self::assertStringContainsString('30 min piano', $notice);
self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice);
self::assertStringContainsString('pending payment', $notice);
}
public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
// The whole point of the no-charge tick: a priced offering raises nothing.
$this->payments->shouldReceive('createForRegistration')->never();
$this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true);
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, '');
self::assertIsString($notice);
self::assertStringContainsString('Nothing is owed', $notice);
}
public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void
{
$slot = $this->slot(recurrenceGroup: 55);
$this->availability->shouldReceive('findById')->with(7)->andReturn($slot);
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([
$slot,
$this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55),
$this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55),
]);
$this->availability->shouldReceive('claim')->times(3)->andReturn(true);
$this->bookings->shouldReceive('insertSeries')->once()
->with(Mockery::type(Lesson::class), [7, 8, 9])
->andReturn([100, 101, 102]);
// A per-lesson (one_time) price is owed once per occurrence claimed.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42)
->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
self::assertIsString($notice);
self::assertStringContainsString('3 weekly lessons', $notice);
}
public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
// Nothing is claimed or written: the staff member asked for a term and is
// told they cannot have one, rather than silently getting one lesson.
$this->availability->shouldReceive('claim')->never();
$this->bookings->shouldReceive('insert')->never();
$this->bookings->shouldReceive('insertSeries')->never();
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('not_weekly', $result->get_error_code());
}
public function testInstructorScopeRefusesAnotherInstructorsTime(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->availability->shouldReceive('claim')->never();
// Slot belongs to instructor 9; My Lessons is scoped to instructor 4.
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_slot', $result->get_error_code());
}
public function testAnAlreadyBookedTimeIsRefused(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true));
$this->availability->shouldReceive('claim')->never();
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('slot_taken', $result->get_error_code());
}
public function testSomeoneWhoCannotBookLessonsIsRefused(): void
{
Functions\when('user_can')->justReturn(false);
$this->availability->shouldReceive('findById')->never();
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_student', $result->get_error_code());
}
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
$this->availability->shouldReceive('claim')->never();
$result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_mismatch', $result->get_error_code());
}
public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Lesson $l): bool => 3 === $l->offeringId
))->andReturn(100);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''));
}
public function testAGeneralTimeNeedsALessonTypeChosen(): void
{
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->availability->shouldReceive('claim')->never();
$result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_required', $result->get_error_code());
}
public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void
{
$this->availability->shouldReceive('findAvailable')
->once()
->with(9, 0, 0, '', '2026-07-27 10:00:00')
->andReturn([$this->slot(recurrenceGroup: 55)]);
$this->offerings->shouldReceive('findAll')
->once()
->with(9, Offering::KIND_PRIVATE_LESSON, true)
->andReturn([$this->offering()]);
$data = $this->admin->formData(9);
self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']);
self::assertSame(1, count($data['slots']));
self::assertTrue($data['slots'][0]['weekly']);
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']);
self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']);
}
public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void
{
$this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]);
$this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]);
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe'));
$data = $this->admin->formData(0);
self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']);
self::assertStringContainsString('30 min piano', $data['slots'][0]['label']);
}
private function slot(
int $id = 7,
string $startDt = '2026-07-01 10:00:00',
bool $isBooked = false,
?int $offeringId = null,
?int $recurrenceGroup = null
): AvailabilitySlot {
return new AvailabilitySlot(
instructorId: 9,
startDt: $startDt,
endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800),
durationMinutes: 30,
offeringId: $offeringId,
isBooked: $isBooked,
recurrenceGroup: $recurrenceGroup,
id: $id,
);
}
private function offering(): Offering
{
return new Offering(
instructorId: 9,
kind: Offering::KIND_PRIVATE_LESSON,
title: '30 min piano',
price: 40.0,
durationMinutes: 30,
isActive: true,
id: 3,
);
}
private function pendingPayment(): Payment
{
return new Payment(
studentId: 42,
instructorId: 9,
registrationType: Payment::REG_LESSON,
registrationId: 100,
amount: 40.0,
status: Payment::STATUS_PENDING,
id: 500,
);
}
private function user(int $id, string $name): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = $id;
$user->first_name = '';
$user->last_name = '';
$user->nickname = $name;
$user->display_name = $name;
$user->user_login = 'jane';
$user->user_email = '[email protected]';
return $user;
}
}