CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking on a per-lesson (one_time) priced offering was creating its single upfront payment for one week's price while reserving up to 12 weeks, and settling that payment confirmed only the anchor lesson, leaving the rest of the series pending forever. - BookingEndpoint now charges price x claimed occurrences for one_time billing; a full_term price is still charged once since it covers the term. - PaymentService::confirmRegistration resolves the anchor lesson's series and confirms every non-cancelled row via the new BookingRepository::updateStatusForSeries(). Closes #79 Co-Authored-By: Claude Fable 5 <[email protected]>
225 lines
7.5 KiB
PHP
225 lines
7.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
|
use Unsupervised\Schedular\Booking\Lesson;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class BookingRepositoryTest extends TestCase
|
|
{
|
|
private \wpdb $db;
|
|
private BookingRepository $repo;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->db = Mockery::mock(\wpdb::class);
|
|
$this->db->prefix = 'wp_';
|
|
$this->repo = new BookingRepository($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_lessons',
|
|
Mockery::on(static function (array $data): bool {
|
|
return $data['slot_id'] === 10
|
|
&& $data['student_id'] === 5
|
|
&& $data['offering_id'] === 7
|
|
&& $data['recurrence'] === Lesson::RECURRENCE_SINGLE
|
|
&& $data['status'] === Lesson::STATUS_PENDING;
|
|
}),
|
|
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s']
|
|
);
|
|
|
|
$this->db->insert_id = 77;
|
|
|
|
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 7);
|
|
$result = $this->repo->insert($lesson);
|
|
|
|
self::assertSame(77, $result);
|
|
}
|
|
|
|
public function testInsertSeriesSharesSeriesIdAcrossSlots(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
|
|
|
$ids = [40, 41, 42];
|
|
$this->db->shouldReceive('insert')
|
|
->times(3)
|
|
->andReturnUsing(function () use (&$ids): void {
|
|
$this->db->insert_id = array_shift($ids);
|
|
});
|
|
|
|
// The first lesson is back-filled with its own id as the series id.
|
|
$this->db->shouldReceive('update')
|
|
->once()
|
|
->with('wp_us_lessons', ['series_id' => 40], ['id' => 40], ['%d'], ['%d']);
|
|
|
|
$template = new Lesson(slotId: 0, studentId: 5, instructorId: 3, offeringId: 7);
|
|
$result = $this->repo->insertSeries($template, [100, 101, 102]);
|
|
|
|
self::assertSame([40, 41, 42], $result);
|
|
}
|
|
|
|
public function testFindByIdReturnsNullWhenNotFound(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_row')->andReturn(null);
|
|
|
|
self::assertNull($this->repo->findById(99));
|
|
}
|
|
|
|
public function testFindByIdReturnsLesson(): void
|
|
{
|
|
$row = (object) [
|
|
'id' => '15',
|
|
'slot_id' => '10',
|
|
'offering_id' => null,
|
|
'student_id' => '5',
|
|
'instructor_id' => '3',
|
|
'recurrence' => Lesson::RECURRENCE_SINGLE,
|
|
'series_id' => null,
|
|
'status' => 'pending',
|
|
'payment_id' => null,
|
|
'notes' => null,
|
|
];
|
|
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_row')->andReturn($row);
|
|
|
|
$lesson = $this->repo->findById(15);
|
|
|
|
self::assertInstanceOf(Lesson::class, $lesson);
|
|
self::assertSame(15, $lesson->id);
|
|
}
|
|
|
|
public function testUpdateStatusReturnsFalseForInvalidStatus(): void
|
|
{
|
|
$result = $this->repo->updateStatus(1, 'invalid');
|
|
self::assertFalse($result);
|
|
}
|
|
|
|
public function testUpdateStatusCallsWpdbUpdate(): void
|
|
{
|
|
$this->db->shouldReceive('update')
|
|
->once()
|
|
->with(
|
|
'wp_us_lessons',
|
|
['status' => Lesson::STATUS_CONFIRMED],
|
|
['id' => 1],
|
|
['%s'],
|
|
['%d']
|
|
)
|
|
->andReturn(1);
|
|
|
|
self::assertTrue($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED));
|
|
}
|
|
|
|
public function testUpdateStatusReturnsFalseWhenDbFails(): void
|
|
{
|
|
$this->db->shouldReceive('update')->andReturn(0);
|
|
|
|
self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED));
|
|
}
|
|
|
|
public function testUpdateStatusForSeriesReturnsFalseForInvalidStatus(): void
|
|
{
|
|
self::assertFalse($this->repo->updateStatusForSeries(12, 'invalid'));
|
|
}
|
|
|
|
public function testUpdateStatusForSeriesUpdatesNonCancelledRows(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(
|
|
Mockery::on(static fn (string $sql): bool =>
|
|
str_contains($sql, 'series_id = %d') && str_contains($sql, 'status != %s')),
|
|
'wp_us_lessons',
|
|
Lesson::STATUS_CONFIRMED,
|
|
12,
|
|
Lesson::STATUS_CANCELLED
|
|
)
|
|
->andReturn('UPDATE ...');
|
|
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(3);
|
|
|
|
self::assertTrue($this->repo->updateStatusForSeries(12, Lesson::STATUS_CONFIRMED));
|
|
}
|
|
|
|
public function testFindUpcomingForStudentJoinsSlotAndExcludesCancelled(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
|
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(Mockery::pattern('/SELECT l\.\*.*l.student_id = %d.*l.status != %s.*a.start_dt >= %s.*ORDER BY a.start_dt ASC/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
|
->andReturn('SELECT ...');
|
|
|
|
$row = (object) [
|
|
'id' => '15',
|
|
'slot_id' => '10',
|
|
'offering_id' => null,
|
|
'student_id' => '5',
|
|
'instructor_id' => '3',
|
|
'recurrence' => Lesson::RECURRENCE_SINGLE,
|
|
'series_id' => null,
|
|
'status' => 'confirmed',
|
|
'payment_id' => null,
|
|
'notes' => null,
|
|
];
|
|
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
|
|
|
$lessons = $this->repo->findUpcomingForStudent(5);
|
|
|
|
self::assertCount(1, $lessons);
|
|
self::assertSame(15, $lessons[0]->id);
|
|
}
|
|
|
|
public function testCountUpcomingForStudent(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
|
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
|
->andReturn('SELECT ...');
|
|
|
|
$this->db->shouldReceive('get_var')->andReturn('3');
|
|
|
|
self::assertSame(3, $this->repo->countUpcomingForStudent(5));
|
|
}
|
|
|
|
public function testFindByStudentReturnsLessons(): void
|
|
{
|
|
$row = (object) [
|
|
'id' => '1',
|
|
'slot_id' => '2',
|
|
'offering_id' => null,
|
|
'student_id' => '5',
|
|
'instructor_id' => '3',
|
|
'recurrence' => Lesson::RECURRENCE_SINGLE,
|
|
'series_id' => null,
|
|
'status' => 'pending',
|
|
'payment_id' => null,
|
|
'notes' => null,
|
|
];
|
|
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
|
|
|
$lessons = $this->repo->findByStudent(5);
|
|
|
|
self::assertCount(1, $lessons);
|
|
self::assertInstanceOf(Lesson::class, $lessons[0]);
|
|
}
|
|
}
|