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