Files
unsupervised-scheduler/tests/Unit/Availability/AvailabilityRepositoryTest.php
T
thatguygriffandClaude Fable 5 ab90dae638
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / PHPStan (push) Successful in 1m11s
CI / Tests (PHP 8.3) (push) Successful in 1m0s
CI / Build Plugin Zip (push) Successful in 1m10s
Let students cancel their own lessons from the booking page
Adds POST /bookings/{id}/cancel (owner-only, idempotent): marks the lesson
cancelled, releases the availability slot for rebooking, and voids a
still-pending payment so it leaves the admin confirmation queue. Paid
payments are untouched — refunds stay a manual admin decision.

The instructor PATCH /bookings/{id}/status path now does the same slot
release and payment voiding on cancellation (previously cancelled lessons
left their slot permanently booked), and reinstating a cancelled lesson
re-claims the slot, rejecting with 409 if the freed time was rebooked.

The "Your upcoming lessons" panel gets a Cancel button with a confirm
prompt; on success both the lesson list and the slot calendar refresh.

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

347 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 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]);
}
}