Files
unsupervised-scheduler/tests/Unit/GroupClass/GroupClassControllerTest.php
T
thatguygriffandClaude Opus 5 5ce42f0003
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio register the students who cannot register themselves
The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.

Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.

Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.

Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.

Closes #185

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
2026-08-24 18:42:59 -03:00

750 lines
33 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassControllerTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings;
private PaymentRepository&Mockery\MockInterface $payments;
private GroupAccessRepository&Mockery\MockInterface $access;
private PaymentService&Mockery\MockInterface $paymentService;
private InviteRepository&Mockery\MockInterface $invites;
private RegistrationMailer&Mockery\MockInterface $mailer;
private IntakeAudit&Mockery\MockInterface $audit;
private IntakeRecording&Mockery\MockInterface $intake;
private GroupClassController $controller;
protected function setUp(): void
{
parent::setUp();
$this->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->audit = Mockery::mock(IntakeAudit::class);
$this->intake = Mockery::mock(IntakeRecording::class);
$this->controller = new GroupClassController(
$this->enrollments,
$this->offerings,
$this->payments,
$this->access,
$this->paymentService,
$this->invites,
$this->mailer,
$this->audit,
$this->intake,
);
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('');
Functions\when('current_time')->justReturn('2026-01-01');
$_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->roles = [RoleManager::STUDENT];
$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 testClassDetailOffersDirectAddForPublicClassWithoutInviteControls(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
// A plain public group class — the instructor can still add students
// directly (a late enrolment), but the invite-only controls are absent.
$offering = $this->offering(8, 'Choir', 10);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('add_direct', $html);
self::assertStringNotContainsString('Invite by email', $html);
self::assertStringNotContainsString('Make available to students', $html);
}
public function testClassDetailFlagsLateEnrolmentPastTheDeadline(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// current_time is stubbed to 2026-01-01, which is past this class's deadline.
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2025-09-08',
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('late enrolments', $html);
self::assertStringContainsString('Add students directly', $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));
// Every posted id is vetted as a student before it is enrolled or granted.
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// 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);
// Stamped with the staff member who added them (user 3), which is what
// later lets the studio record the intake it never had a chance to ask for.
$this->enrollments->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Enrollment $e): bool => 3 === $e->enrolledBy && $e->isStaffRegistered()
))->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 testEnrollmentIdOpensTheIntakeDetailView(): void
{
$_GET = ['enrollment_id' => '44'];
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// enrolled_by 3: the studio added this student, so intake can be recorded.
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
$this->expectEnrollmentDetail($enrollment);
$this->intake->shouldReceive('pending')->once()->with($enrollment)->andReturn([
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
]);
$html = $this->renderInstructor();
self::assertStringContainsString('Enrolment details', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('Record intake collected elsewhere', $html);
self::assertStringContainsString('Anything we should know?', $html);
self::assertStringContainsString('How were these collected?', $html);
}
public function testAnEnrolmentTheStudentMadeOffersNoRecordingForm(): void
{
$_GET = ['enrollment_id' => '44'];
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// enrolled_by 0: the student enrolled themselves and gave their own answers.
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44);
$this->expectEnrollmentDetail($enrollment);
$this->intake->shouldNotReceive('pending');
self::assertStringNotContainsString('Record intake collected elsewhere', $this->renderInstructor());
}
public function testAnInstructorCannotOpenAnotherInstructorsEnrolment(): void
{
$_GET = ['enrollment_id' => '44'];
// Enrolment belongs to instructor 9; the current user is 3.
$this->enrollments->shouldReceive('findById')->once()->with(44)
->andReturn(new Enrollment(offeringId: 8, studentId: 5, instructorId: 9, enrolledBy: 9, id: 44));
$this->audit->shouldNotReceive('answers');
$this->intake->shouldNotReceive('pending');
$html = $this->renderInstructor();
self::assertStringContainsString('This enrolment could not be found.', $html);
self::assertStringNotContainsString('Record intake collected elsewhere', $html);
}
public function testSubmittedEnrolmentIntakeIsRecordedAndReported(): void
{
$_GET = ['enrollment_id' => '44'];
$_POST = [
'usc_action' => 'record_intake',
'answers' => ['9' => 'Nut allergy'],
'accepted_policy_version_ids' => ['6'],
'collected_via' => 'phone',
'collected_note' => 'Called the parent',
];
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_textarea_field')->returnArg();
Functions\when('wp_unslash')->returnArg();
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
$this->expectEnrollmentDetail($enrollment);
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
$this->intake->shouldReceive('record')
->once()
->with($enrollment, [9 => 'Nut allergy'], [6], 'phone', 'Called the parent', 3)
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: Over the phone');
$html = $this->renderInstructor();
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
self::assertStringContainsString('notice-success', $html);
// The generic class-form handler must not also run and report a missing class.
self::assertStringNotContainsString('That group class was not found.', $html);
}
/** The lookups the enrolment detail view makes, with an empty audit trail. */
private function expectEnrollmentDetail(Enrollment $enrollment): void
{
$this->enrollments->shouldReceive('findById')->once()->with(44)->andReturn($enrollment);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(8, 'Choir', 10));
$this->audit->shouldReceive('answers')->with($enrollment)->andReturn([]);
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
}
/**
* The multi-select is built from the studio's students, but a posted id is just
* a number: it could name an instructor, an administrator, or an account
* deleted since the page was drawn. Enrolling one would put a non-student on
* the roster and raise a payment against them.
*/
public function testAddDirectIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$this->access->shouldReceive('markEnrolled')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testAddDirectIgnoresAnAccountThatNoLongerExists(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
Functions\when('get_userdata')->justReturn(false);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testGrantAccessIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->access->shouldReceive('insert')->never();
$this->mailer->shouldReceive('sendClassAccessGranted')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) granted access.', $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->roles = [RoleManager::STUDENT];
$user->user_email = '[email protected]';
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' => '[email protected]'];
$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('[email protected]')->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('[email protected]', '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' => '[email protected]'];
$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('[email protected]')->andReturn(
new \Unsupervised\Schedular\Auth\Invite(email: '[email protected]', 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' => '[email protected]'];
$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 = '[email protected]';
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);
}
}