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, no offering; another request wins the claim first. $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); $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]); $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 testBookWithoutOfferingConfirmsImmediatelyWithNoPayment(): void { $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); $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]); $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 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 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']); } }