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

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:
2026-07-05 22:09:27 -03:00
co-authored by Claude Fable 5
parent ab90dae638
commit c7d5d72c9c
4 changed files with 228 additions and 25 deletions
+91 -8
View File
@@ -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