Files
unsupervised-scheduler/tests/Unit/Availability/AvailabilityRepositoryTest.php
T
thatguygriffandClaude Fable 5 b7d5e3039e
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / No Debug Code (pull_request) Failing after 2s
CI / Coding Standards (pull_request) Successful in 52s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
Split availability windows into bookable lesson-length slots with weekly calendar views
Availability windows were stored and served as a single bookable row, so a
9:00 AM-4:00 PM window showed to students as one giant slot and booking it
consumed the whole day; past and multi-day windows also leaked into the
booking page as nonsense entries.

- Split windows into consecutive lesson-length slots on save (REST and admin
  form); each chunk is independently bookable and weekly recurrence creates a
  series per chunk so "reserve this time weekly" holds the same hour each week
- Reject windows spanning multiple days or shorter than the lesson length
  (400 invalid_window)
- Never return slots whose start has passed from GET /availability
- Migrate pre-split rows: Plugin::boot re-runs the Installer on version change
  and AvailabilityRepository::splitOversizedWindows() rewrites unbooked
  same-day oversized windows in place
- Display all times in 12-hour AM/PM form (booking page, wp-admin lists,
  editor previews)
- Add a List | Week view toggle to the student booking page and the
  instructor availability page, with previous/next-week navigation honouring
  the site's start_of_week option (new WeekCalendar helper)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 16:00:47 -03:00

337 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Availability;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AvailabilityRepositoryTest extends TestCase
{
private \wpdb $db;
private AvailabilityRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->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 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]);
}
}