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('
42 | ', $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();
}
}