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']); } }