Group class term dates, single-class embed mode, and offering editing
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
Group class offerings now carry real dates: the add/edit form takes a start date plus a sessions control (one-off, or weekly for N sessions; the end date is computed as start + (N-1) weeks via Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown in the offerings list and on the student-facing class card, including the weekly session count. [us_group_classes offering="<id>"] (block attribute offeringId, chosen from a dropdown of active classes fetched from the public offerings endpoint) restricts the page to a single class so the enrolment flow can be embedded on a page dedicated to that class; a pinned class that is no longer offered reports itself closed instead of falling back to the catalog. Offerings are now editable from the admin screen: an Edit button prefills the shared add/edit form and saving posts usc_action=update. Updates always preserve the original owner and currency, and non-admin instructors can only load and update their own offerings. The form also gains the previously missing description field and an Active toggle (the admin-UI counterpart of the REST is_active flag) so an edit cannot wipe data the form never collected. Closes #59 Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
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 OfferingController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->repository = Mockery::mock(OfferingRepository::class);
|
||||
$this->controller = new OfferingController($this->repository);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,30 @@ class OfferingTest extends TestCase
|
||||
self::assertSame(42, $offering->id);
|
||||
}
|
||||
|
||||
public function testNormalizeDateAcceptsRealDates(): void
|
||||
{
|
||||
self::assertSame('2026-09-08', Offering::normalizeDate('2026-09-08'));
|
||||
}
|
||||
|
||||
public function testNormalizeDateRejectsGarbage(): void
|
||||
{
|
||||
self::assertNull(Offering::normalizeDate(''));
|
||||
self::assertNull(Offering::normalizeDate('not-a-date'));
|
||||
self::assertNull(Offering::normalizeDate('2026-02-30'));
|
||||
self::assertNull(Offering::normalizeDate('2026-09-08 10:00'));
|
||||
}
|
||||
|
||||
public function testWeeklyTermEndAddsOneWeekPerExtraSession(): void
|
||||
{
|
||||
self::assertSame('2026-11-10', Offering::weeklyTermEnd('2026-09-08', 10));
|
||||
}
|
||||
|
||||
public function testWeeklyTermEndOfSingleSessionIsTheStartDate(): void
|
||||
{
|
||||
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 1));
|
||||
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0));
|
||||
}
|
||||
|
||||
public function testDefaults(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
||||
|
||||
Reference in New Issue
Block a user