Files
unsupervised-scheduler/tests/Unit/Booking/BookingEndpointTest.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

640 lines
32 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\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingEndpointTest extends TestCase
{
private AvailabilityRepository $availability;
private BookingRepository $bookings;
private OfferingRepository $offerings;
private RegistrationGate $gate;
private PaymentService $payments;
private StudioSettings $settings;
private BookingEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5);
// Fixed "now" well before the fixture slot start (2026-07-01 10:00), so
// the cancellation cutoff never trips unless a test moves it.
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->gate = Mockery::mock(RegistrationGate::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault();
$this->endpoint = new BookingEndpoint(
$this->availability,
$this->bookings,
$this->offerings,
$this->gate,
$this->payments,
new CancellationPolicy($this->settings),
);
}
private function slot(int $id, int $instructorId, ?int $offeringId, bool $isBooked = false, ?int $recurrenceGroup = null): AvailabilitySlot
{
return new AvailabilitySlot(
instructorId: $instructorId,
startDt: '2026-07-01 10:00:00',
endDt: '2026-07-01 11:00:00',
offeringId: $offeringId,
isBooked: $isBooked,
recurrenceGroup: $recurrenceGroup,
id: $id,
);
}
public function testBookRejectsOfferingFromAnotherInstructor(): void
{
// Generic slot owned by instructor 3; attacker supplies instructor 7's offering.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(99)->andReturn(
new Offering(instructorId: 7, kind: Offering::KIND_PRIVATE_LESSON, title: 'Cheap', price: 0.0, id: 99)
);
// No booking should be created.
$this->availability->shouldNotReceive('claim');
$this->bookings->shouldNotReceive('insert');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 99]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_mismatch', $result->get_error_code());
}
public function testBookRejectsOfferingThatDoesNotMatchSlotTiedOffering(): void
{
// Slot is tied to offering 5; attacker tries to swap in offering 99.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 5));
$this->offerings->shouldNotReceive('findById');
$this->availability->shouldNotReceive('claim');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 99]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_mismatch', $result->get_error_code());
}
public function testBookReturns409WhenSlotClaimFails(): void
{
// Generic slot; another request wins the claim first.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false);
$this->bookings->shouldNotReceive('insert');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('slot_taken', $result->get_error_code());
}
public function testBookSucceedsForGenericSlotWithSameInstructorOffering(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
// Free offering → no payment, so the lesson is confirmed immediately.
$this->payments->shouldNotReceive('createForRegistration');
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
self::assertSame([77], $result->get_data()['ids']);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
self::assertNull($result->get_data()['payment']);
}
public function testBookWithoutOfferingIsRejected(): void
{
// A booking with no offering would be silently free, so it must be refused.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldNotReceive('findById');
$this->availability->shouldNotReceive('claim');
$this->bookings->shouldNotReceive('insert');
$request = new \WP_REST_Request(['slot_id' => 10]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_required', $result->get_error_code());
}
public function testBookUsesSlotTiedOfferingWhenRequestOmitsIt(): void
{
// Slot tied to offering 5: the booking must charge that offering even
// though the client sent no offering_id.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 5));
$this->offerings->shouldReceive('findById')->with(5)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 5)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => 5 === $l->offeringId))
->andReturn(77);
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
registrationType: Payment::REG_LESSON,
registrationId: 77,
amount: 50.0,
method: Payment::METHOD_ETRANSFER,
status: Payment::STATUS_PENDING,
id: 12,
));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
}
public function testBookRejectsInactiveStudentChosenOffering(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Retired', price: 50.0, isActive: false, id: 8)
);
$this->availability->shouldNotReceive('claim');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_offering', $result->get_error_code());
}
public function testBookRejectsGroupClassOfferingForLessonSlot(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 10.0, id: 8)
);
$this->availability->shouldNotReceive('claim');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_offering', $result->get_error_code());
}
public function testBookRejectsOfferingWhoseDurationDoesNotFitSlot(): void
{
// Slot is 60 minutes (the helper default); a 30-minute offering cannot book it.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Short', price: 25.0, durationMinutes: 30, id: 8)
);
$this->availability->shouldNotReceive('claim');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('offering_mismatch', $result->get_error_code());
}
public function testBookWithPricedOfferingStaysPendingAndReturnsPaymentSummary(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
registrationType: Payment::REG_LESSON,
registrationId: 77,
amount: 50.0,
method: Payment::METHOD_ETRANSFER,
status: Payment::STATUS_PENDING,
id: 12,
));
// Awaiting payment: the lesson must not be confirmed yet.
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
self::assertSame(
['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING],
$result->get_data()['payment']
);
}
public function testBookWithCompedPaymentReturnsConfirmedStatus(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
// Comped students are paid on creation (PaymentService confirms the lesson itself).
$this->payments->shouldReceive('createForRegistration')->once()->andReturn(new Payment(
studentId: 5,
instructorId: 3,
registrationType: Payment::REG_LESSON,
registrationId: 77,
amount: 50.0,
method: Payment::METHOD_COMP,
status: Payment::STATUS_PAID,
id: 12,
));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']);
}
public function testWeeklyBookingChargesPerLessonPriceTimesClaimedOccurrences(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null, false, 7));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, allowWeekly: true, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('findUnbookedInGroup')->with(7)->andReturn([
$this->slot(10, 3, null, false, 7),
$this->slot(11, 3, null, false, 7),
$this->slot(12, 3, null, false, 7),
]);
$this->availability->shouldReceive('claim')->times(3)->andReturn(true);
$this->bookings->shouldReceive('insertSeries')->once()->andReturn([77, 78, 79]);
$this->gate->shouldReceive('record')->once();
// Three claimed occurrences at a per-lesson (one_time) price of 50 → 150.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame([77, 78, 79], $result->get_data()['ids']);
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
}
public function testWeeklyBookingChargesFullTermPriceOnce(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null, false, 7));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(
instructorId: 3,
kind: Offering::KIND_PRIVATE_LESSON,
title: 'Term',
price: 400.0,
billingMode: Offering::BILLING_FULL_TERM,
allowWeekly: true,
id: 8
)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('findUnbookedInGroup')->with(7)->andReturn([
$this->slot(10, 3, null, false, 7),
$this->slot(11, 3, null, false, 7),
]);
$this->availability->shouldReceive('claim')->times(2)->andReturn(true);
$this->bookings->shouldReceive('insertSeries')->once()->andReturn([77, 78]);
$this->gate->shouldReceive('record')->once();
// A full_term price already covers the whole reservation.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
}
public function testScheduledBillingDefersPaymentAndConfirmsLesson(): void
{
// Weekly/monthly offerings are billed later by the daily scan, not at
// booking: no payment is created now, and the reserved lesson is confirmed.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, billingMode: Offering::BILLING_WEEKLY, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
$this->payments->shouldNotReceive('createForRegistration');
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
self::assertNull($result->get_data()['payment']);
}
public function testMonthlyLessonInAlreadyBilledMonthChargesAtBooking(): void
{
// "now" is 2026-06-01; a monthly lesson booked into June (its billing 1st
// already reached) is an add-on and must be charged at booking, not deferred.
$this->availability->shouldReceive('findById')->with(10)->andReturn(
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
self::assertNotNull($result->get_data()['payment']);
}
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
{
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
// so it defers to the daily scan (no payment now, lesson confirmed).
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
$this->payments->shouldNotReceive('createForRegistration');
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
self::assertNull($result->get_data()['payment']);
}
public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testCancelWithinStudioCutoffIsRejected(): void
{
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
// exactly the studio cutoff — inside the window, so cancellation closes.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
instructorId: 3,
startDt: '2026-06-02 09:59:00',
endDt: '2026-06-02 10:59:00',
id: 10,
));
$this->bookings->shouldNotReceive('updateStatus');
$this->availability->shouldNotReceive('release');
$this->payments->shouldNotReceive('voidPending');
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('cancellation_closed', $result->get_error_code());
}
public function testCancelUsesOfferingCutoffOverrideWhenSet(): void
{
// Offering overrides the 24h studio default with 0 hours — cancel any time,
// even for a lesson starting in a minute.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 4, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
instructorId: 3,
startDt: '2026-06-01 10:01:00',
endDt: '2026-06-01 11:01:00',
id: 10,
));
$this->offerings->shouldReceive('findById')->with(4)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_PRIVATE_LESSON,
title: 'Trial',
cancellationCutoffHours: 0,
));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testCancelByAnotherStudentIsForbidden(): void
{
// Lesson belongs to student 9; current user is 5.
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->bookings->shouldNotReceive('updateStatus');
$this->availability->shouldNotReceive('release');
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
}
public function testCancelUnknownLessonReturns404(): void
{
$this->bookings->shouldReceive('findById')->with(99)->andReturn(null);
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 99]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('not_found', $result->get_error_code());
}
public function testCancelAlreadyCancelledLessonIsIdempotent(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->bookings->shouldNotReceive('updateStatus');
$this->availability->shouldNotReceive('release');
$this->payments->shouldNotReceive('voidPending');
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testUpdateStatusToCancelledReleasesSlotAndVoidsPayment(): void
{
// Current user 5 is the lesson's instructor.
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CANCELLED]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testUpdateStatusReinstatingCancelledLessonReclaimsSlot(): void
{
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_CANCELLED, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
$result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CONFIRMED]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
}
public function testUpdateStatusReinstatingFailsWhenSlotRebooked(): void
{
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_CANCELLED, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
// Someone booked the freed time in the meantime.
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false);
$this->bookings->shouldNotReceive('updateStatus');
$result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CONFIRMED]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('slot_taken', $result->get_error_code());
}
public function testMyLessonsForStudentIncludesSlotTimes(): void
{
Functions\when('current_user_can')->justReturn(false);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, 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, null));
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
$data = $result->get_data();
self::assertCount(1, $data);
self::assertSame(77, $data[0]['id']);
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']);
}
}