db = Mockery::mock(\wpdb::class); $this->db->prefix = 'wp_'; $this->repo = new AvailabilityRepository($this->db); } public function testInsertCallsWpdbInsertAndReturnsId(): void { Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00'); $this->db->shouldReceive('insert') ->once() ->with( 'wp_us_availability', Mockery::on(static function (array $data): bool { return $data['instructor_id'] === 5 && $data['start_dt'] === '2026-04-01 09:00:00' && $data['duration_minutes'] === 30 && $data['offering_id'] === 8 && $data['is_booked'] === 0; }), ['%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s'] ); $this->db->insert_id = 42; $slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8); $result = $this->repo->insert($slot); self::assertSame(42, $result); } public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void { Functions\when('current_time')->justReturn('2026-04-07 12:00:00'); $captured = []; $ids = [10, 11, 12]; $this->db->shouldReceive('insert') ->times(3) ->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void { $captured[] = $data['start_dt']; $this->db->insert_id = array_shift($ids); }); // The first row is back-filled with its own id as the recurrence group. $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['recurrence_group' => 10], ['id' => 10], ['%d'], ['%d']); $first = new AvailabilitySlot(5, '2026-04-07 09:00:00', '2026-04-07 10:00:00', 60); $result = $this->repo->createWeeklySeries($first, 3); self::assertSame([10, 11, 12], $result); self::assertSame( ['2026-04-07 09:00:00', '2026-04-14 09:00:00', '2026-04-21 09:00:00'], $captured ); } public function testFindByIdReturnsNullWhenNotFound(): void { $this->db->shouldReceive('prepare') ->once() ->andReturn('SELECT * FROM wp_us_availability WHERE id = 99'); $this->db->shouldReceive('get_row') ->once() ->andReturn(null); $result = $this->repo->findById(99); self::assertNull($result); } public function testFindByIdReturnsSlotWhenFound(): void { $row = (object) [ 'id' => '10', 'instructor_id' => '5', 'offering_id' => null, 'start_dt' => '2026-04-01 09:00:00', 'end_dt' => '2026-04-01 10:00:00', 'duration_minutes' => '60', 'is_booked' => '0', 'recurrence_group' => null, ]; $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); $this->db->shouldReceive('get_row')->andReturn($row); $slot = $this->repo->findById(10); self::assertInstanceOf(AvailabilitySlot::class, $slot); self::assertSame(10, $slot->id); self::assertSame(5, $slot->instructorId); } public function testClaimReturnsTrueWhenSlotWasUnbooked(): void { $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['is_booked' => 1], ['id' => 7, 'is_booked' => 0], ['%d'], ['%d', '%d']) ->andReturn(1); self::assertTrue($this->repo->claim(7)); } public function testClaimReturnsFalseWhenSlotAlreadyBooked(): void { // The is_booked = 0 guard matches no row once the slot is taken. $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['is_booked' => 1], ['id' => 7, 'is_booked' => 0], ['%d'], ['%d', '%d']) ->andReturn(0); self::assertFalse($this->repo->claim(7)); } public function testReleaseFreesTheSlot(): void { $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['is_booked' => 0], ['id' => 7], ['%d'], ['%d']) ->andReturn(1); self::assertTrue($this->repo->release(7)); } public function testDeleteReturnsFalseWhenRowNotDeleted(): void { $this->db->shouldReceive('delete') ->once() ->with('wp_us_availability', ['id' => 1, 'is_booked' => 0], ['%d', '%d']) ->andReturn(0); self::assertFalse($this->repo->delete(1)); } public function testFindAvailableWithNoFiltersExcludesPastSlots(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $this->db->shouldReceive('prepare') ->once() ->with( Mockery::pattern('/WHERE is_booked = 0 AND start_dt >= %s/'), ['wp_us_availability', '2026-07-05 12:00:00'] ) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results') ->once() ->with('SELECT ...') ->andReturn([]); $result = $this->repo->findAvailable(); self::assertSame([], $result); } public function testFindAvailableWithInstructorFilterPreparesQuery(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $this->db->shouldReceive('prepare') ->once() ->with(Mockery::pattern('/instructor_id = %d/'), Mockery::any()) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([]); $this->repo->findAvailable(instructorId: 3); } public function testFindAvailableWithOfferingAndDurationFilters(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $this->db->shouldReceive('prepare') ->once() ->with( Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'), Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', '2026-07-05 12:00:00', 8, 30]) ) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([]); $this->repo->findAvailable(offeringId: 8, durationMinutes: 30); } public function testCreateFromWindowInsertsOneRowPerLessonLengthChunk(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $captured = []; $ids = [21, 22, 23]; $this->db->shouldReceive('insert') ->times(3) ->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void { $captured[] = [$data['start_dt'], $data['end_dt']]; $this->db->insert_id = array_shift($ids); }); $window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 12:00:00', 60); $result = $this->repo->createFromWindow($window); self::assertSame([21, 22, 23], $result); self::assertSame( [ ['2026-07-06 09:00:00', '2026-07-06 10:00:00'], ['2026-07-06 10:00:00', '2026-07-06 11:00:00'], ['2026-07-06 11:00:00', '2026-07-06 12:00:00'], ], $captured ); } public function testCreateFromWindowWeeklyCreatesASeriesPerChunk(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $captured = []; $ids = [30, 31, 40, 41]; // Two chunks × two weeks: each chunk becomes its own weekly series. $this->db->shouldReceive('insert') ->times(4) ->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void { $captured[] = $data['start_dt']; $this->db->insert_id = array_shift($ids); }); // Each series back-fills its first row with its own recurrence group. $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['recurrence_group' => 30], ['id' => 30], ['%d'], ['%d']); $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['recurrence_group' => 40], ['id' => 40], ['%d'], ['%d']); $window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 11:00:00', 60); $result = $this->repo->createFromWindow($window, weekly: true, weeks: 2); self::assertSame([30, 31, 40, 41], $result); self::assertSame( [ '2026-07-06 09:00:00', '2026-07-13 09:00:00', '2026-07-06 10:00:00', '2026-07-13 10:00:00', ], $captured ); } public function testSplitOversizedWindowsTrimsRowAndInsertsRemainingChunks(): void { Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); $row = (object) [ 'id' => '9', 'instructor_id' => '5', 'offering_id' => null, 'start_dt' => '2026-07-06 09:00:00', 'end_dt' => '2026-07-06 12:00:00', 'duration_minutes' => '60', 'is_booked' => '0', 'recurrence_group' => null, ]; $this->db->shouldReceive('prepare') ->once() ->with(Mockery::pattern('/TIMESTAMPDIFF\(MINUTE, start_dt, end_dt\) > duration_minutes/'), 'wp_us_availability') ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn([$row]); // The original row is trimmed to the first lesson-length chunk. $this->db->shouldReceive('update') ->once() ->with('wp_us_availability', ['end_dt' => '2026-07-06 10:00:00'], ['id' => 9], ['%s'], ['%d']); // The remaining two chunks become new rows. $inserted = []; $this->db->shouldReceive('insert') ->times(2) ->andReturnUsing(function (string $table, array $data) use (&$inserted): void { $inserted[] = [$data['start_dt'], $data['end_dt']]; $this->db->insert_id = 50; }); $this->repo->splitOversizedWindows(); self::assertSame( [ ['2026-07-06 10:00:00', '2026-07-06 11:00:00'], ['2026-07-06 11:00:00', '2026-07-06 12:00:00'], ], $inserted ); } public function testFindByInstructorReturnsSlots(): void { $row = (object) [ 'id' => '5', 'instructor_id' => '3', 'offering_id' => null, 'start_dt' => '2026-04-01 09:00:00', 'end_dt' => '2026-04-01 10:00:00', 'duration_minutes' => '60', 'is_booked' => '0', 'recurrence_group' => null, ]; $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([$row]); $slots = $this->repo->findByInstructor(3); self::assertCount(1, $slots); self::assertInstanceOf(AvailabilitySlot::class, $slots[0]); } }