CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking on a per-lesson (one_time) priced offering was creating its single upfront payment for one week's price while reserving up to 12 weeks, and settling that payment confirmed only the anchor lesson, leaving the rest of the series pending forever. - BookingEndpoint now charges price x claimed occurrences for one_time billing; a full_term price is still charged once since it covers the term. - PaymentService::confirmRegistration resolves the anchor lesson's series and confirms every non-cancelled row via the new BookingRepository::updateStatusForSeries(). Closes #79 Co-Authored-By: Claude Fable 5 <[email protected]>
488 lines
23 KiB
PHP
488 lines
23 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\Lesson;
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Payment\Payment;
|
|
use Unsupervised\Schedular\Payment\PaymentService;
|
|
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 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);
|
|
|
|
$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->endpoint = new BookingEndpoint(
|
|
$this->availability,
|
|
$this->bookings,
|
|
$this->offerings,
|
|
$this->gate,
|
|
$this->payments,
|
|
);
|
|
}
|
|
|
|
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 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->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']);
|
|
}
|
|
}
|