CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Group classes gain an instructor-set enrolment deadline (new us_offerings.enrollment_deadline column) that defaults to the first day of the class (term_start). Past the deadline students can no longer self-enrol: the enrolment endpoint rejects it (403 enrollment_closed) and the front-end class list shows "Enrolment has closed." in place of the Enrol button. Instructors keep a manual path: the "Add students directly" control on each class's details page now renders for public classes too (not just invite-only) and deliberately bypasses the deadline and capacity, so a student can be added as a late enrolment after the class has closed. Past the deadline the details page labels these as late enrolments. Bumps USC_VERSION to 1.1.3 for the schema change. Co-Authored-By: Claude Opus 4.8 <[email protected]>
424 lines
16 KiB
PHP
424 lines
16 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use Mockery;
|
||
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
|
||
use Unsupervised\Schedular\Offering\Offering;
|
||
use Unsupervised\Schedular\Offering\OfferingController;
|
||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||
|
||
class OfferingControllerTest extends TestCase
|
||
{
|
||
private OfferingRepository&Mockery\MockInterface $repository;
|
||
private ClassSlotReconciler&Mockery\MockInterface $reconciler;
|
||
private OfferingController $controller;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->repository = Mockery::mock(OfferingRepository::class);
|
||
$this->reconciler = Mockery::mock(ClassSlotReconciler::class);
|
||
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault();
|
||
$this->controller = new OfferingController($this->repository, $this->reconciler);
|
||
|
||
$_POST = [];
|
||
$_GET = [];
|
||
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_current_user_id')->justReturn(3);
|
||
Functions\when('get_users')->justReturn([]);
|
||
Functions\when('check_admin_referer')->justReturn(true);
|
||
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
||
Functions\when('add_query_arg')->alias(
|
||
static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value
|
||
);
|
||
Functions\when('wp_unslash')->returnArg();
|
||
Functions\when('sanitize_text_field')->returnArg();
|
||
Functions\when('sanitize_textarea_field')->returnArg();
|
||
Functions\when('sanitize_email')->returnArg();
|
||
Functions\when('sanitize_key')->alias(
|
||
static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key))
|
||
);
|
||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||
Functions\when('wp_nonce_field')->justReturn('');
|
||
Functions\when('submit_button')->alias(static function (string $text = ''): void {
|
||
echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub
|
||
});
|
||
Functions\when('mysql2date')->alias(
|
||
static fn (string $format, string $date) => date($format, (int) strtotime($date))
|
||
);
|
||
}
|
||
|
||
public function testAddGroupClassWithWeeklyTermComputesEndDate(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Ballet Beginners',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'billing_mode' => Offering::BILLING_FULL_TERM,
|
||
'capacity' => '8',
|
||
'term_start' => '2026-09-08',
|
||
'term_recurrence' => 'weekly',
|
||
'term_sessions' => '10',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-11-10' === $o->termEnd
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testAddGroupClassStoresClassTimeAndReconcilesSlots(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Ballet Beginners',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'term_start' => '2026-09-08',
|
||
'class_time' => '16:30',
|
||
'duration_minutes' => '60',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => '16:30:00' === $o->classTime
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
// A scheduled class is reconciled against the instructor's availability,
|
||
// and the resulting notice is surfaced to the admin.
|
||
$this->reconciler->shouldReceive('reconcile')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => '16:30:00' === $o->classTime
|
||
))->andReturn(['removed' => 2, 'conflicts' => []]);
|
||
|
||
$html = $this->render();
|
||
|
||
self::assertStringContainsString('2 open booking slots were removed', $html);
|
||
}
|
||
|
||
public function testAddGroupClassStoresEnrollmentDeadline(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Ballet Beginners',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'term_start' => '2026-09-08',
|
||
'enrollment_deadline' => '2026-08-31',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => '2026-08-31' === $o->enrollmentDeadline
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testBlankEnrollmentDeadlineLeavesItNullToDefaultToFirstClass(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'term_start' => '2026-09-08',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => null === $o->enrollmentDeadline
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testGarbageClassTimeIsRejected(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'class_time' => 'not-a-time',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => null === $o->classTime
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testStudioAdminAssignsClassToChosenInstructor(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'class_instructor_id' => '7',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => 7 === $o->instructorId
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testInstructorCannotReassignClassToAnotherInstructor(): void
|
||
{
|
||
// A plain instructor (no manage_instructors) — the posted instructor id
|
||
// must be ignored so the class stays theirs.
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap) => 'manage_instructors' !== $cap
|
||
);
|
||
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'class_instructor_id' => '7',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => 3 === $o->instructorId
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Private Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'invite_only' => '1',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => Offering::ACCESS_INVITE_ONLY === $o->accessMode
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testAddWithoutInviteOnlyDefaultsToPublicAccess(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Open Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => Offering::ACCESS_PUBLIC === $o->accessMode
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testAddOneOffGroupClassEndsOnItsStartDate(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Recital Workshop',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'term_start' => '2026-09-08',
|
||
'term_recurrence' => 'single',
|
||
'term_sessions' => '10',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-09-08' === $o->termEnd
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testInvalidTermStartLeavesTermDatesNull(): void
|
||
{
|
||
$_POST = [
|
||
'usc_action' => 'add',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'term_start' => 'not-a-date',
|
||
'term_recurrence' => 'weekly',
|
||
'term_sessions' => '10',
|
||
];
|
||
|
||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||
static fn (Offering $o) => null === $o->termStart && null === $o->termEnd
|
||
))->andReturn(1);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testOfferingListShowsIdAndTermRange(): void
|
||
{
|
||
$offering = new Offering(
|
||
instructorId: 3,
|
||
kind: Offering::KIND_GROUP_CLASS,
|
||
title: 'Ballet Beginners',
|
||
termStart: '2026-09-08',
|
||
termEnd: '2026-11-10',
|
||
id: 42,
|
||
);
|
||
|
||
$this->repository->shouldReceive('findAll')->andReturn([$offering]);
|
||
|
||
$html = $this->render();
|
||
|
||
self::assertStringContainsString('<td>42</td>', $html);
|
||
self::assertStringContainsString('Sep 8, 2026 – Nov 10, 2026', $html);
|
||
}
|
||
|
||
public function testUpdateAppliesChangesButPreservesOwnerAndCurrency(): void
|
||
{
|
||
$existing = new Offering(
|
||
instructorId: 9,
|
||
kind: Offering::KIND_GROUP_CLASS,
|
||
title: 'Ballet Beginners',
|
||
currency: 'USD',
|
||
id: 42,
|
||
);
|
||
|
||
$_POST = [
|
||
'usc_action' => 'update',
|
||
'offering_id' => '42',
|
||
'title' => 'Ballet Intermediate',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
'description' => 'A step up.',
|
||
'capacity' => '6',
|
||
'is_active' => '1',
|
||
];
|
||
|
||
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing);
|
||
$this->repository->shouldReceive('update')->once()->with(42, Mockery::on(
|
||
static fn (Offering $o) => 9 === $o->instructorId
|
||
&& 'USD' === $o->currency
|
||
&& 'Ballet Intermediate' === $o->title
|
||
&& 'A step up.' === $o->description
|
||
&& 6 === $o->capacity
|
||
&& $o->isActive
|
||
))->andReturn(true);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testUpdateWithoutActiveCheckboxDeactivatesTheOffering(): void
|
||
{
|
||
$existing = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||
|
||
$_POST = [
|
||
'usc_action' => 'update',
|
||
'offering_id' => '42',
|
||
'title' => 'Choir',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
];
|
||
|
||
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing);
|
||
$this->repository->shouldReceive('update')->once()->with(42, Mockery::on(
|
||
static fn (Offering $o) => ! $o->isActive
|
||
))->andReturn(true);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testNonAdminCannotUpdateAnotherInstructorsOffering(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap) => 'manage_instructors' !== $cap
|
||
);
|
||
|
||
$foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||
|
||
$_POST = [
|
||
'usc_action' => 'update',
|
||
'offering_id' => '42',
|
||
'title' => 'Hijacked',
|
||
'kind' => Offering::KIND_GROUP_CLASS,
|
||
];
|
||
|
||
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign);
|
||
$this->repository->shouldNotReceive('update');
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
}
|
||
|
||
public function testEditQueryParamPrefillsTheForm(): void
|
||
{
|
||
$editing = new Offering(
|
||
instructorId: 3,
|
||
kind: Offering::KIND_GROUP_CLASS,
|
||
title: 'Ballet Beginners',
|
||
description: 'For new dancers.',
|
||
capacity: 8,
|
||
termStart: '2026-09-08',
|
||
termEnd: '2026-11-10',
|
||
id: 42,
|
||
);
|
||
|
||
$_GET = ['usc_edit' => '42'];
|
||
|
||
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($editing);
|
||
$this->repository->shouldReceive('findAll')->andReturn([$editing]);
|
||
|
||
$html = $this->render();
|
||
|
||
self::assertStringContainsString('Edit Offering', $html);
|
||
self::assertStringContainsString('value="update"', $html);
|
||
self::assertStringContainsString('name="offering_id" value="42"', $html);
|
||
self::assertStringContainsString('value="Ballet Beginners"', $html);
|
||
self::assertStringContainsString('For new dancers.', $html);
|
||
self::assertStringContainsString('value="2026-09-08"', $html);
|
||
// 2026-09-08 → 2026-11-10 is ten weekly sessions.
|
||
self::assertStringContainsString('name="term_sessions" min="1" max="52" value="10"', $html);
|
||
self::assertStringContainsString('value="weekly" checked', $html);
|
||
self::assertStringContainsString('Update Offering', $html);
|
||
}
|
||
|
||
public function testNonAdminCannotLoadAnotherInstructorsOfferingIntoTheForm(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap) => 'manage_instructors' !== $cap
|
||
);
|
||
|
||
$foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||
|
||
$_GET = ['usc_edit' => '42'];
|
||
|
||
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign);
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$html = $this->render();
|
||
|
||
self::assertStringContainsString('Add Offering', $html);
|
||
self::assertStringNotContainsString('Edit Offering', $html);
|
||
}
|
||
|
||
private function render(): string
|
||
{
|
||
ob_start();
|
||
$this->controller->renderPage();
|
||
|
||
return (string) ob_get_clean();
|
||
}
|
||
}
|