Files
unsupervised-scheduler/tests/Unit/Availability/AvailabilityRepositoryTest.php
T
thatguygriffandClaude Opus 4.8 b066bef353
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Add group-class scheduling, instructor assignment, and details/invite management
Group classes now carry a specific class time (alongside date and duration)
and an assigned instructor:

- Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains
  `normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta
  upgrade, so no version bump.)
- Offering form: class-time field, plus a studio-admin instructor picker
  (plain instructors always own their own classes).
- `ClassSlotReconciler`: assigning an instructor clears their open booking
  slots overlapping each session and flags already-booked lessons that clash
  (a booked lesson is never deleted). Uses new
  `AvailabilityRepository::findOverlapping`.
- Front end: `GET /offerings` exposes `instructor_name`; the enrolment page
  shows who teaches each class and when it meets.

Back-office group-class views redesigned:

- Instructor **My Group Classes** and studio-admin **Group Classes** are now
  per-class summaries with enrolment counts, not flat student lists.
- Each links through (`?class_id=<id>`) to a per-class **details page**
  (schedule panel, roster with payment status, and — for invite-only classes
  — the add/make-available/invite-by-email controls). Invite-only membership
  is managed entirely from this page.
- Invite actions are allowed for the class's owning instructor or any
  `view_all_lessons` studio admin, so an owner-operator (studio admin who also
  teaches) can reach every class's roster and invites from the Group Classes
  page.

Tests: composer test (508), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 17:29:48 -03:00

380 lines
13 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 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]);
}
public function testFindOverlappingQueriesTheInstructorAndWindow(): void
{
$row = (object) [
'id' => '5',
'instructor_id' => '3',
'offering_id' => null,
'start_dt' => '2026-09-08 16:00:00',
'end_dt' => '2026-09-08 17:00:00',
'duration_minutes' => '60',
'is_booked' => '0',
'recurrence_group' => null,
];
// Half-open overlap: start_dt < window end AND end_dt > window start, with
// the window bounds bound in that order.
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/start_dt < %s AND end_dt > %s/'),
'wp_us_availability',
3,
'2026-09-08 17:00:00',
'2026-09-08 16:00:00'
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$row]);
$slots = $this->repo->findOverlapping(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00');
self::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
}
}