Files
unsupervised-scheduler/tests/Unit/Offering/OfferingControllerTest.php
T
thatguygriffandClaude Opus 4.8 2c4b481077
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Add group-class withdrawal deadline and kind-aware offering form
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.

Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.

- New nullable us_offerings.withdrawal_deadline column; Offering model gains
  $withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
  (403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
  under its section, matching the scheduled-billing and credit features.

Tests: composer test (596), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:56:40 -03:00

461 lines
17 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 testAddGroupClassStoresWithdrawalDeadline(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'withdrawal_deadline' => '2026-08-31',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '2026-08-31' === $o->withdrawalDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testBlankWithdrawalDeadlineLeavesItNull(): 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->withdrawalDeadline
))->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();
}
}