CI / Coding Standards (pull_request) Successful in 27s
CI / Tests (PHP 8.1) (pull_request) Successful in 37s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.3) (pull_request) Successful in 36s
CI / Tests (PHP 8.5) (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Static Analysis (pull_request) Successful in 48s
CI / Build Plugin Zip (pull_request) Skipped
Add an opt-in, per-instructor email notice sent when someone books one of their lessons or enrols in one of their group classes. Off by default and set from My Availability → Notifications; covers both the student/guardian REST flows and the studio's wp-admin "book/add for a student" forms. The opt-in check lives in InstructorNotificationMailer so no booking path can drift on who is mailed; lessons fire from LessonBooker::settle (the one step both booking paths reach), enrolments from EnrollmentEndpoint::enroll and GroupClassController::addDirect. The notice is a courtesy and never fails a booking or enrolment that otherwise succeeded. Co-authored-by: anthropic/claude-opus-4-8
394 lines
17 KiB
PHP
394 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
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))
|
|
);
|
|
// The picker offers holders of the student role, and that is what the guard
|
|
// accepts; it is exercised on its own below.
|
|
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe'));
|
|
Functions\when('get_users')->justReturn([]);
|
|
// 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);
|
|
// A charge raised at booking has the payer's credit applied before it
|
|
// settles. The default holds no balance and re-reads the same payment.
|
|
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
|
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
|
static fn (int $id): ?Payment => null
|
|
)->byDefault();
|
|
$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.
|
|
// The opt-in instructor notice is tested on its own; a mock keeps these
|
|
// tests about booking, not about who gets emailed.
|
|
$instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
|
|
$instructorMailer->shouldReceive('notifyLessonBooked')->andReturn(true)->byDefault();
|
|
|
|
$this->admin = new AdminBooking(
|
|
$this->availability,
|
|
$this->offerings,
|
|
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians, $instructorMailer)
|
|
);
|
|
}
|
|
|
|
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 testSomeoneWhoIsNotAStudentIsRefused(): void
|
|
{
|
|
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR]));
|
|
$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 testAnAccountThatNoLongerExistsIsRefused(): void
|
|
{
|
|
Functions\when('get_userdata')->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 testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void
|
|
{
|
|
Functions\expect('get_userdata')->never();
|
|
$this->availability->shouldReceive('findById')->never();
|
|
|
|
$result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('invalid_student', $result->get_error_code());
|
|
}
|
|
|
|
/**
|
|
* A child holds the student role but never `book_lesson` — withheld so the
|
|
* account cannot book in its own name. The studio booking for them is the only
|
|
* route a child has to a lesson, so it must not be blocked by that.
|
|
*/
|
|
public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void
|
|
{
|
|
Functions\when('user_can')->justReturn(false);
|
|
$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);
|
|
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
|
|
|
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
|
|
|
self::assertIsString($notice);
|
|
self::assertStringContainsString('30 min piano', $notice);
|
|
}
|
|
|
|
/**
|
|
* Same for a self-signup the studio has not approved yet: the front desk can
|
|
* still get them onto the calendar while the paperwork catches up.
|
|
*/
|
|
public function testBooksForAStudentStillAwaitingApproval(): void
|
|
{
|
|
Functions\when('user_can')->justReturn(false);
|
|
$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 => 42 === $l->studentId
|
|
))->andReturn(100);
|
|
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
|
|
|
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
|
|
|
self::assertIsString($notice);
|
|
self::assertStringContainsString('pending payment', $notice);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/** @param list<string> $roles */
|
|
private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User
|
|
{
|
|
$user = Mockery::mock(\WP_User::class);
|
|
$user->ID = $id;
|
|
$user->roles = $roles;
|
|
$user->first_name = '';
|
|
$user->last_name = '';
|
|
$user->nickname = $name;
|
|
$user->display_name = $name;
|
|
$user->user_login = 'jane';
|
|
$user->user_email = '[email protected]';
|
|
|
|
return $user;
|
|
}
|
|
}
|