alias(static fn ($v): int => abs((int) $v)); Functions\when('current_time')->justReturn('2026-06-01 10:00:00'); Functions\when('mysql2date')->alias( static fn (string $format, string $date): string => date($format, (int) strtotime($date)) ); // The picker offers holders of the student role, and that is what the guard // accepts; it is exercised on its own below. Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe')); Functions\when('get_users')->justReturn([]); // The staff member doing the booking; stamped on the lesson as booked_by. Functions\when('get_current_user_id')->justReturn(3); $this->availability = Mockery::mock(AvailabilityRepository::class); $this->bookings = Mockery::mock(BookingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); $this->payments = Mockery::mock(PaymentService::class); $this->guardians = Mockery::mock(GuardianService::class); $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); // The real booker over mocked repositories: an admin booking must go // through exactly the machinery a student's own booking does. $this->admin = new AdminBooking( $this->availability, $this->offerings, new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians) ); } public function testBooksASingleLessonAndRaisesAPendingPayment(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); $this->bookings->shouldReceive('insert')->once()->with(Mockery::on( static fn (Lesson $l): bool => 7 === $l->slotId && 42 === $l->studentId && 9 === $l->instructorId && 3 === $l->offeringId && Lesson::RECURRENCE_SINGLE === $l->recurrence && 'Booked by phone' === $l->notes // Stamped with who booked it, which is what later lets the studio // record the intake it never had a chance to collect. && 3 === $l->bookedBy ))->andReturn(100); $this->payments->shouldReceive('createForRegistration') ->once() ->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42) ->andReturn($this->pendingPayment()); $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone'); self::assertIsString($notice); self::assertStringContainsString('30 min piano', $notice); self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice); self::assertStringContainsString('pending payment', $notice); } public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); $this->bookings->shouldReceive('insert')->once()->andReturn(100); // The whole point of the no-charge tick: a priced offering raises nothing. $this->payments->shouldReceive('createForRegistration')->never(); $this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true); $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, ''); self::assertIsString($notice); self::assertStringContainsString('Nothing is owed', $notice); } public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void { $slot = $this->slot(recurrenceGroup: 55); $this->availability->shouldReceive('findById')->with(7)->andReturn($slot); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([ $slot, $this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55), $this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55), ]); $this->availability->shouldReceive('claim')->times(3)->andReturn(true); $this->bookings->shouldReceive('insertSeries')->once() ->with(Mockery::type(Lesson::class), [7, 8, 9]) ->andReturn([100, 101, 102]); // A per-lesson (one_time) price is owed once per occurrence claimed. $this->payments->shouldReceive('createForRegistration') ->once() ->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42) ->andReturn($this->pendingPayment()); $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, ''); self::assertIsString($notice); self::assertStringContainsString('3 weekly lessons', $notice); } public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); // Nothing is claimed or written: the staff member asked for a term and is // told they cannot have one, rather than silently getting one lesson. $this->availability->shouldReceive('claim')->never(); $this->bookings->shouldReceive('insert')->never(); $this->bookings->shouldReceive('insertSeries')->never(); $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('not_weekly', $result->get_error_code()); } public function testInstructorScopeRefusesAnotherInstructorsTime(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->availability->shouldReceive('claim')->never(); // Slot belongs to instructor 9; My Lessons is scoped to instructor 4. $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('invalid_slot', $result->get_error_code()); } public function testAnAlreadyBookedTimeIsRefused(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true)); $this->availability->shouldReceive('claim')->never(); $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('slot_taken', $result->get_error_code()); } public function testSomeoneWhoIsNotAStudentIsRefused(): void { Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR])); $this->availability->shouldReceive('findById')->never(); $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('invalid_student', $result->get_error_code()); } public function testAnAccountThatNoLongerExistsIsRefused(): void { Functions\when('get_userdata')->justReturn(false); $this->availability->shouldReceive('findById')->never(); $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('invalid_student', $result->get_error_code()); } public function testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void { Functions\expect('get_userdata')->never(); $this->availability->shouldReceive('findById')->never(); $result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('invalid_student', $result->get_error_code()); } /** * A child holds the student role but never `book_lesson` — withheld so the * account cannot book in its own name. The studio booking for them is the only * route a child has to a lesson, so it must not be blocked by that. */ public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void { Functions\when('user_can')->justReturn(false); $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); $this->bookings->shouldReceive('insert')->once()->andReturn(100); $this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment()); $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertIsString($notice); self::assertStringContainsString('30 min piano', $notice); } /** * Same for a self-signup the studio has not approved yet: the front desk can * still get them onto the calendar while the paperwork catches up. */ public function testBooksForAStudentStillAwaitingApproval(): void { Functions\when('user_can')->justReturn(false); $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); $this->bookings->shouldReceive('insert')->once()->with(Mockery::on( static fn (Lesson $l): bool => 42 === $l->studentId ))->andReturn(100); $this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment()); $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); self::assertIsString($notice); self::assertStringContainsString('pending payment', $notice); } public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3)); $this->availability->shouldReceive('claim')->never(); $result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('offering_mismatch', $result->get_error_code()); } public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3)); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); $this->bookings->shouldReceive('insert')->once()->with(Mockery::on( static fn (Lesson $l): bool => 3 === $l->offeringId ))->andReturn(100); $this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment()); self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '')); } public function testAGeneralTimeNeedsALessonTypeChosen(): void { $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); $this->availability->shouldReceive('claim')->never(); $result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('offering_required', $result->get_error_code()); } public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void { $this->availability->shouldReceive('findAvailable') ->once() ->with(9, 0, 0, '', '2026-07-27 10:00:00') ->andReturn([$this->slot(recurrenceGroup: 55)]); $this->offerings->shouldReceive('findAll') ->once() ->with(9, Offering::KIND_PRIVATE_LESSON, true) ->andReturn([$this->offering()]); $data = $this->admin->formData(9); self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']); self::assertSame(1, count($data['slots'])); self::assertTrue($data['slots'][0]['weekly']); self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']); self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']); } public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void { $this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]); $this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]); $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe')); $data = $this->admin->formData(0); self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']); self::assertStringContainsString('30 min piano', $data['slots'][0]['label']); } private function slot( int $id = 7, string $startDt = '2026-07-01 10:00:00', bool $isBooked = false, ?int $offeringId = null, ?int $recurrenceGroup = null ): AvailabilitySlot { return new AvailabilitySlot( instructorId: 9, startDt: $startDt, endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800), durationMinutes: 30, offeringId: $offeringId, isBooked: $isBooked, recurrenceGroup: $recurrenceGroup, id: $id, ); } private function offering(): Offering { return new Offering( instructorId: 9, kind: Offering::KIND_PRIVATE_LESSON, title: '30 min piano', price: 40.0, durationMinutes: 30, isActive: true, id: 3, ); } private function pendingPayment(): Payment { return new Payment( studentId: 42, instructorId: 9, registrationType: Payment::REG_LESSON, registrationId: 100, amount: 40.0, status: Payment::STATUS_PENDING, id: 500, ); } /** @param list $roles */ private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User { $user = Mockery::mock(\WP_User::class); $user->ID = $id; $user->roles = $roles; $user->first_name = ''; $user->last_name = ''; $user->nickname = $name; $user->display_name = $name; $user->user_login = 'jane'; $user->user_email = 'jane@example.test'; return $user; } }