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. $this->payments->shouldNotReceive('createForRegistration'); $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']); } }