Files
unsupervised-scheduler/tests/Unit/Offering/OfferingControllerTest.php
T
thatguygriffandClaude Opus 4.8 a281935811
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Add invite-only group classes
Group classes can now be marked invite-only (us_offerings.access_mode).
Invite-only classes are hidden from the public catalog and reachable only
when the instructor lets someone in via one of three paths, managed from
My Lessons -> My Group Classes:

- Add students directly: enrols them now with a pending payment.
- Make available: grants registered students access to self-enrol through
  the normal paid flow (multi-select, emailed a notice).
- Invite by email: tokenised registration invite tied to the class for a
  non-account address; after they register the class becomes enrollable.
  Reuses an existing pending invite instead of sending a second link.

New us_group_access table records grants; GET /offerings merges granted
invite-only classes for the caller; enrolment requires a grant
(403 invite_required) and flips it to enrolled on success.

composer test (487), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 13:51:02 -03:00

298 lines
11 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\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 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();
}
}