Require an offering on every lesson booking, with a student-facing picker
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m40s
CI / PHPStan (pull_request) Successful in 2m24s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m40s
CI / PHPStan (pull_request) Successful in 2m24s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Has been skipped
Generic slots (no tied offering) were bookable with no offering at all: free, instantly confirmed, and with no intake questions. POST /bookings now rejects offering-less bookings (400 offering_required), and a student-chosen offering must be an active private-lesson type owned by the slot's instructor whose duration matches the slot. The registration form gains a Lesson type field: locked to the slot's tied offering (title, duration, price) so the student sees what they are booking, or a required picker of fitting offerings for generic slots, with intake questions following the selection. Fixes #55 Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -98,13 +98,16 @@ class BookingEndpointTest extends TestCase
|
||||
|
||||
public function testBookReturns409WhenSlotClaimFails(): void
|
||||
{
|
||||
// Generic slot, no offering; another request wins the claim first.
|
||||
// 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]);
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
@@ -135,22 +138,102 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testBookWithoutOfferingConfirmsImmediatelyWithNoPayment(): void
|
||||
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()->andReturn(77);
|
||||
$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->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
$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_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user