enrollments = Mockery::mock(EnrollmentRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); $this->payments = Mockery::mock(PaymentRepository::class); $this->access = Mockery::mock(GroupAccessRepository::class); $this->paymentService = Mockery::mock(PaymentService::class); $this->invites = Mockery::mock(InviteRepository::class); $this->mailer = Mockery::mock(RegistrationMailer::class); $this->controller = new GroupClassController( $this->enrollments, $this->offerings, $this->payments, $this->access, $this->paymentService, $this->invites, $this->mailer, ); Functions\when('current_user_can')->justReturn(true); Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_users')->justReturn([]); Functions\when('esc_attr')->returnArg(); Functions\when('esc_attr_e')->returnArg(); Functions\when('esc_url')->returnArg(); Functions\when('admin_url')->justReturn('admin.php?page=us-my-group-classes'); Functions\when('add_query_arg')->alias( static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value ); Functions\when('absint')->alias(static fn ($v) => abs((int) $v)); Functions\when('mysql2date')->alias( static fn (string $format, string $date) => date($format, (int) strtotime($date)) ); Functions\when('wp_nonce_field')->justReturn(''); $_GET = []; } /** * A WP_User whose real name (and display name) is the given full name, so * both instructor resolution (first + last) and roster display (display name) * render it. */ private function userNamed(string $full): \WP_User { [$first, $last] = array_pad(explode(' ', $full, 2), 2, ''); $user = Mockery::mock(\WP_User::class); $user->first_name = $first; $user->last_name = $last; $user->nickname = $full; $user->display_name = $full; return $user; } private function offering(int $id, string $title, ?int $capacity): Offering { return new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: $title, capacity: $capacity, id: $id, ); } private function renderInstructor(): string { ob_start(); $this->controller->renderInstructorPage(); return (string) ob_get_clean(); } public function testInstructorSummaryListsClassWithEnrolmentCountAndRosterLink(): void { $offering = $this->offering(8, 'Choir', 10); $active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1); $this->offerings->shouldReceive('findAll')->once() ->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$active]); // The summary must not resolve individual students — it is a class list. $this->payments->shouldReceive('findById')->never(); $html = $this->renderInstructor(); self::assertStringContainsString('Choir', $html); self::assertStringContainsString('1 / 10', $html); self::assertStringContainsString('View details', $html); self::assertStringContainsString('class_id=8', $html); } public function testClassDetailListsRosterWithPaymentStatus(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Choir', 10); $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1); $this->offerings->shouldReceive('findAll')->once() ->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$enrollment]); $this->payments->shouldReceive('findById')->once()->with(42)->andReturn( new Payment(studentId: 5, instructorId: 3, registrationType: Payment::REG_ENROLLMENT, registrationId: 1, amount: 100.0, status: Payment::STATUS_PAID, id: 42) ); $html = $this->renderInstructor(); self::assertStringContainsString('1 / 10 enrolled', $html); self::assertStringContainsString('Ada Lovelace', $html); self::assertStringContainsString('paid', $html); } public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', price: 120.0, description: 'A year of choir.', durationMinutes: 60, termStart: '2026-09-08', termEnd: '2026-09-08', classTime: '16:00:00', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8, ); $this->offerings->shouldReceive('findAll')->once() ->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); $this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]); $html = $this->renderInstructor(); // The details section. self::assertStringContainsString('Class details', $html); self::assertStringContainsString('Ada Lovelace', $html); self::assertStringContainsString('120.00', $html); self::assertStringContainsString('A year of choir.', $html); // The invite/add controls are reached from this page. self::assertStringContainsString('Add students directly', $html); self::assertStringContainsString('Invite by email', $html); } public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Band', null); $active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); $cancelled = new Enrollment(offeringId: 8, studentId: 6, instructorId: 3, status: Enrollment::STATUS_CANCELLED, id: 2); $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$active, $cancelled]); $html = $this->renderInstructor(); // Unlimited capacity offering counts only the active enrolment. self::assertStringContainsString('1 enrolled', $html); // But the roster still shows the cancelled row. self::assertStringContainsString('cancelled', $html); } public function testClassDetailFreeEnrolmentShowsDashForPayment(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Alan Turing')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Theory', 5); $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: null, id: 1); $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$enrollment]); $this->payments->shouldReceive('findById')->never(); $html = $this->renderInstructor(); self::assertStringContainsString('—', $html); } public function testEnrolmentsForOtherClassesAreNotMixedIn(): void { $offering = $this->offering(8, 'Choir', 5); $mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); $other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2); $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$mine, $other]); $html = $this->renderInstructor(); self::assertStringContainsString('1 / 5', $html); } public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Jazz', 5); $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]); $html = $this->renderInstructor(); self::assertStringContainsString('No enrolments yet.', $html); } public function testInstructorWithNoClassesShowsEmptyMessage(): void { $this->offerings->shouldReceive('findAll')->once()->andReturn([]); $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]); $html = $this->renderInstructor(); self::assertStringContainsString('You have no group classes.', $html); } public function testStudioAdminPageSummarisesClassesNotStudents(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $offering = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', capacity: 10, termStart: '2026-09-08', termEnd: '2026-09-08', classTime: '16:00:00', id: 8, ); $this->offerings->shouldReceive('findAll')->once() ->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); $this->enrollments->shouldReceive('countActiveForOffering')->once()->with(8)->andReturn(4); ob_start(); $this->controller->renderPage(); $html = (string) ob_get_clean(); self::assertStringContainsString('Choir', $html); self::assertStringContainsString('Ada Lovelace', $html); self::assertStringContainsString('4 / 10', $html); } public function testStudioAdminCanOpenClassDetailWithInviteControls(): void { // A studio admin (view_all_lessons) opens a class taught by instructor 7. Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = new Offering( instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', price: 120.0, accessMode: Offering::ACCESS_INVITE_ONLY, id: 8, ); $this->offerings->shouldReceive('findAll')->once() ->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); // Detail rosters are looked up by the class's own instructor (7). $this->enrollments->shouldReceive('findByInstructor')->once()->with(7)->andReturn([]); $this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]); ob_start(); $this->controller->renderPage(); $html = (string) ob_get_clean(); self::assertStringContainsString('Class details', $html); self::assertStringContainsString('Add students directly', $html); self::assertStringContainsString('Invite by email', $html); } public function testDeniesUsersWithoutViewLessonsCapability(): void { Functions\when('current_user_can')->justReturn(false); Functions\expect('wp_die')->once()->andThrow(new \RuntimeException('denied')); $this->expectException(\RuntimeException::class); $this->controller->renderInstructorPage(); } protected function tearDown(): void { $_POST = []; $_GET = []; parent::tearDown(); } private function inviteOnlyOffering(float $price = 0.0): Offering { return new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', price: $price, accessMode: Offering::ACCESS_INVITE_ONLY, id: 8, ); } /** Stub the form-processing helpers and the render tail shared by all action tests. */ private function stubActionContext(): void { Functions\when('check_admin_referer')->justReturn(true); Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); Functions\when('wp_unslash')->returnArg(); Functions\when('sanitize_email')->returnArg(); Functions\when('absint')->alias(static fn ($v) => abs((int) $v)); // Render tail: no classes/enrolments to draw so the assertion targets the notice. $this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]); $this->enrollments->shouldReceive('findByInstructor')->with(3)->andReturn([]); } public function testAddDirectEnrolsStudentWithPendingPayment(): void { $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]]; $this->stubActionContext(); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0)); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); $this->enrollments->shouldReceive('insert')->once()->andReturn(44); $payment = new Payment( studentId: 5, instructorId: 3, registrationType: Payment::REG_ENROLLMENT, registrationId: 44, amount: 100.0, method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12, ); $this->paymentService->shouldReceive('createForRegistration') ->once() ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 100.0, 'CAD', null) ->andReturn($payment); $this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true); $this->access->shouldReceive('markEnrolled')->once()->with(8, 5); $html = $this->renderInstructor(); self::assertStringContainsString('1 student(s) added to the class.', $html); } public function testGrantAccessCreatesGrantAndEmailsStudent(): void { $_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]]; $this->stubActionContext(); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); $this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false); $this->access->shouldReceive('insert')->once()->andReturn(1); $user = Mockery::mock(\WP_User::class); $user->user_email = 'ada@b.test'; Functions\when('get_userdata')->justReturn($user); $this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true); $html = $this->renderInstructor(); self::assertStringContainsString('1 student(s) granted access.', $html); } public function testInviteEmailForNewAddressCreatesInviteAndSendsLink(): void { $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'new@x.test']; $this->stubActionContext(); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); $this->invites->shouldReceive('findPendingByEmail')->with('new@x.test')->andReturn(null); Functions\when('wp_generate_password')->justReturn('rawtoken'); Functions\when('get_option')->justReturn(0); Functions\when('home_url')->justReturn('http://home.test/'); Functions\when('add_query_arg')->justReturn('http://home.test/?us_invite=rawtoken'); $this->invites->shouldReceive('insert')->once()->andReturn(7); $this->access->shouldReceive('insert')->once()->andReturn(2); $this->mailer->shouldReceive('sendClassInvite')->once()->with('new@x.test', 'http://home.test/?us_invite=rawtoken', 'Private Choir')->andReturn(true); $html = $this->renderInstructor(); self::assertStringContainsString('Invitation sent.', $html); } public function testInviteEmailReusesPendingInviteWithoutSendingLink(): void { $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'new@x.test']; $this->stubActionContext(); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); $this->invites->shouldReceive('findPendingByEmail')->with('new@x.test')->andReturn( new \Unsupervised\Schedular\Auth\Invite(email: 'new@x.test', token: 'hash', id: 9) ); // No new invite row and no email — just a grant attached to the existing invite. $this->invites->shouldReceive('insert')->never(); $this->mailer->shouldReceive('sendClassInvite')->never(); $this->access->shouldReceive('insert')->once()->andReturn(2); $html = $this->renderInstructor(); self::assertStringContainsString('No new link was sent.', $html); } public function testInviteEmailForExistingAccountGrantsAccess(): void { $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'has@acct.test']; $this->stubActionContext(); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(55); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 55)->andReturn(false); $this->access->shouldReceive('hasGrant')->with(8, 55)->andReturn(false); $this->access->shouldReceive('insert')->once()->andReturn(3); $user = Mockery::mock(\WP_User::class); $user->user_email = 'has@acct.test'; Functions\when('get_userdata')->justReturn($user); $this->mailer->shouldReceive('sendClassAccessGranted')->once()->andReturn(true); $html = $this->renderInstructor(); self::assertStringContainsString('1 student(s) granted access.', $html); } public function testActionRejectedForClassNotOwnedByPlainInstructor(): void { // A plain instructor (no view_all_lessons) may only manage their own classes. Functions\when('current_user_can')->alias( static fn (string $cap) => 'view_all_lessons' !== $cap ); $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]]; $this->stubActionContext(); // Offering owned by a different instructor (7, not the current user 3). $foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8); $this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign); $this->enrollments->shouldReceive('insert')->never(); $html = $this->renderInstructor(); self::assertStringContainsString('That group class was not found.', $html); } public function testStudioAdminCanManageInviteForAnotherInstructorsClass(): void { // current_user_can returns true for everything (incl. view_all_lessons), // so the studio admin may add students to a class they do not own. $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]]; $this->stubActionContext(); $foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', price: 100.0, accessMode: Offering::ACCESS_INVITE_ONLY, id: 8); $this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); $this->enrollments->shouldReceive('insert')->once()->andReturn(44); // The enrolment and payment use the class's own instructor (7), not the admin. $payment = new Payment( studentId: 5, instructorId: 7, registrationType: Payment::REG_ENROLLMENT, registrationId: 44, amount: 100.0, status: Payment::STATUS_PENDING, id: 12, ); $this->paymentService->shouldReceive('createForRegistration') ->once()->with(Payment::REG_ENROLLMENT, 44, 5, 7, 100.0, 'CAD', null)->andReturn($payment); $this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true); $this->access->shouldReceive('markEnrolled')->once()->with(8, 5); $html = $this->renderInstructor(); self::assertStringContainsString('1 student(s) added to the class.', $html); } }