CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section: - Fixed-size fields (question labels, offering titles/notes/e-transfer email, policy titles/slugs) no longer silently fail to save when the value exceeds its column length. The REST endpoints reject over-long values with a 400, the admin controllers refuse to insert them, and the form inputs carry a maxlength so the browser blocks over-long entry. Limits are MAX_* constants on the value objects, kept in lockstep with the schema columns. - Students are kept out of wp-admin entirely. New StudentAdminGuard redirects front-end-only users (no back-office capability) away from the dashboard and hides the admin bar for them, while administrators, studio admins, and instructors keep full access. - The Add/Edit Offering instructor picker now includes WordPress administrators when they act as instructors (the default single-account setup), so a solo studio owner is selectable instead of the dropdown being empty. composer test (618), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
508 lines
19 KiB
PHP
508 lines
19 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([]);
|
||
// Default single-account setup: admins act as instructors.
|
||
Functions\when('get_option')->justReturn('1');
|
||
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);
|
||
}
|
||
|
||
public function testInstructorPickerIncludesAdministratorsWhenTheyActAsInstructors(): void
|
||
{
|
||
// The reported bug: a solo studio owner runs the business from a WordPress
|
||
// administrator account and teaches through the dynamic capability grant,
|
||
// so they never hold the us_instructor role. The picker must still list
|
||
// them, otherwise there is no one to assign a class to.
|
||
Functions\when('get_option')->justReturn('1');
|
||
|
||
$admin = Mockery::mock(\WP_User::class);
|
||
$admin->ID = 3;
|
||
$admin->display_name = 'Studio Owner';
|
||
|
||
$queriedRoles = [];
|
||
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles, $admin): array {
|
||
$queriedRoles = $args['role__in'];
|
||
return [$admin];
|
||
});
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$html = $this->render();
|
||
|
||
self::assertContains('us_instructor', $queriedRoles);
|
||
self::assertContains('administrator', $queriedRoles);
|
||
self::assertStringContainsString('Studio Owner', $html);
|
||
self::assertStringContainsString('<option value="3"', $html);
|
||
}
|
||
|
||
public function testInstructorPickerExcludesAdministratorsWhenGrantDisabled(): void
|
||
{
|
||
// With the "admins are instructors" toggle off, an admin is not a teacher,
|
||
// so only the explicit us_instructor role is queried.
|
||
Functions\when('get_option')->justReturn('0');
|
||
|
||
$queriedRoles = null;
|
||
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles): array {
|
||
$queriedRoles = $args['role__in'];
|
||
return [];
|
||
});
|
||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||
|
||
$this->render();
|
||
|
||
self::assertSame(['us_instructor'], $queriedRoles);
|
||
}
|
||
|
||
private function render(): string
|
||
{
|
||
ob_start();
|
||
$this->controller->renderPage();
|
||
|
||
return (string) ob_get_clean();
|
||
}
|
||
}
|