Files
unsupervised-scheduler/tests/Unit/Offering/OfferingControllerTest.php
T
thatguygriffandClaude Opus 4.8 bf29162587
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
Add group-class enrolment deadline with instructor late-enrolment override
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]>
2026-07-24 10:19:38 -03:00

424 lines
16 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\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();
}
}