Files
unsupervised-scheduler/tests/Unit/Availability/AvailabilityRepositoryTest.php
thatguygriff 171b655bb8
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Stop the availability form failing in silence
Adding availability for 5:30-6:00 PM with the lesson length left on its
60-minute default saved nothing and said nothing. A window is stored as
consecutive lesson-length slots, so one that fits no lesson splits into
none: splitByDuration() returned [], createFromWindow() inserted
nothing, and addSlot() discarded the result and re-rendered the page
unchanged.

The REST endpoint already rejected that window with a 400. The admin
form checked the same rules separately, and its copy was both laxer and
mute — an unreadable date, an end before the start, and a two-day window
were bare `return`s, and it never checked offering ownership at all, so
a crafted POST could tie a slot to another instructor's offering and
inherit their price and payment routing.

Both callers now go through WindowValidator, which returns the window or
a WP_Error explaining the refusal. The endpoint returns that error as
is; the page renders its message as a notice. handleFormAction returns
a [notice, error] pair so deletes report themselves too, and a
successful add says how many slots it created.

Two failures could also go unnoticed underneath: wpdb::insert's result
was ignored, and insert_id still holds the previous statement's id after
a failed write, so a failure looked like a success — and could become
the recurrence group of a weekly series, orphaning every later
occurrence. weeks was unbounded server-side despite the form's max=52.

availability-admin.js narrows the lesson-length choices to those that
fit the window and blocks submission when none do, which is what makes
the original mistake hard to repeat. It is a convenience: the server
validates regardless.

Closes #130
2026-07-28 23:19:49 -03:00

475 lines
16 KiB
PHP
Raw Permalink 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 testInsertReturnsZeroWhenTheWriteFails(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
// wpdb::insert returns false on error, but insert_id still holds the
// previous statement's id — returning it made a failed write look like a
// successful one.
$this->db->shouldReceive('insert')->once()->andReturn(false);
$this->db->insert_id = 42;
$slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30);
self::assertSame(0, $this->repo->insert($slot));
}
public function testCreateFromWindowOmitsChunksThatFailedToInsert(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// Three chunks; the middle write fails.
$results = [null, false, null];
$ids = [11, 13];
$this->db->shouldReceive('insert')
->times(3)
->andReturnUsing(function () use (&$results, &$ids) {
$outcome = array_shift($results);
if (false !== $outcome) {
$this->db->insert_id = array_shift($ids);
}
return $outcome;
});
$window = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:30:00', 30);
self::assertSame([11, 13], $this->repo->createFromWindow($window));
}
public function testWeeklySeriesIsClampedToTheMaximum(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// The form's max is advisory; a hand-crafted POST could ask for any
// number, so the ceiling is enforced here.
$next = 1;
$this->db->shouldReceive('insert')
->times(AvailabilitySlot::MAX_WEEKLY_OCCURRENCES)
->andReturnUsing(function () use (&$next) {
$this->db->insert_id = $next++;
return null;
});
$this->db->shouldReceive('update')->once();
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
self::assertCount(
AvailabilitySlot::MAX_WEEKLY_OCCURRENCES,
$this->repo->createWeeklySeries($first, 10000)
);
}
public function testWeeklySeriesGroupsOnTheFirstRowThatActuallyWrote(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// The first insert fails. A failed row must not become the recurrence
// group (its id is 0), which would orphan every later occurrence.
$results = [false, null, null];
$ids = [21, 22];
$this->db->shouldReceive('insert')
->times(3)
->andReturnUsing(function () use (&$results, &$ids) {
$outcome = array_shift($results);
if (false !== $outcome) {
$this->db->insert_id = array_shift($ids);
}
return $outcome;
});
// The group is set from row 21 — the first that survived.
$this->db->shouldReceive('update')
->once()
->with('wp_us_availability', ['recurrence_group' => 21], ['id' => 21], ['%d'], ['%d']);
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
self::assertSame([21, 22], $this->repo->createWeeklySeries($first, 3));
}
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]);
}
}