The assessment looked for three things: whether students can reach each other's bookings, whether payment settings can be dodged, and whether the plugin opens a way into the rest of the install. The student-isolation and payment paths held up. These are what did not. - The front-end login form told WordPress not to work out whether the site was secure, so on HTTPS every student's session cookie was issued without the Secure flag. wp_signon() only derives it from is_ssl() when the second argument is left at its default; an explicit false reads like "no preference" and is not. - The update check took whatever download URL the release API returned and handed it to core, which unpacks it over the installed plugin. The package must now be https on git.unsupervised.ca exactly, compared on the parsed host so a lookalike name cannot pass. - Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook signing key in wp_options. Removal is now a choice made in advance on Access -> Plugin removal: records are kept unless the owner opts in (with a typed confirmation), while credentials and the borrowed core registration settings go every time. - Open registration switches on the site-wide users_can_register and makes Student the default role, arming any other signup form on the site to mint students who could book and be billed immediately. The pending state is now decided once, on user_register, rather than by whichever form created the account. - Cancel and withdraw answered "not yours" differently from "does not exist", which let a signed-in student enumerate the studio's bookings. Both now give the same 404. Co-Authored-By: Claude Opus 5 <[email protected]>
346 lines
16 KiB
PHP
346 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
|
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Payment\Payment;
|
|
use Unsupervised\Schedular\Payment\PaymentService;
|
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
|
use Unsupervised\Schedular\Registration\RegistrationGate;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class EnrollmentEndpointTest extends TestCase
|
|
{
|
|
private GuardianService&Mockery\MockInterface $guardians;
|
|
private EnrollmentRepository $enrollments;
|
|
private OfferingRepository $offerings;
|
|
private RegistrationGate $gate;
|
|
private PaymentService $payments;
|
|
private GroupAccessRepository $access;
|
|
private EnrollmentEndpoint $endpoint;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
|
Functions\when('wp_unslash')->returnArg();
|
|
Functions\when('sanitize_text_field')->returnArg();
|
|
Functions\when('get_current_user_id')->justReturn(5);
|
|
Functions\when('current_time')->justReturn('2026-07-24');
|
|
|
|
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
|
$this->gate = Mockery::mock(RegistrationGate::class);
|
|
$this->payments = Mockery::mock(PaymentService::class);
|
|
$this->access = Mockery::mock(GroupAccessRepository::class);
|
|
|
|
$this->guardians = Mockery::mock(GuardianService::class);
|
|
$this->guardians->shouldReceive('canActFor')
|
|
->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
|
|
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
|
|
$this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
|
|
|
|
$this->endpoint = new EnrollmentEndpoint(
|
|
$this->enrollments,
|
|
$this->offerings,
|
|
$this->gate,
|
|
$this->payments,
|
|
$this->access,
|
|
$this->guardians,
|
|
);
|
|
}
|
|
|
|
private function offering(float $price): Offering
|
|
{
|
|
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8);
|
|
}
|
|
|
|
private function inviteOnlyOffering(): Offering
|
|
{
|
|
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
|
|
}
|
|
|
|
private function expectSuccessfulEnrollment(): void
|
|
{
|
|
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
|
$this->enrollments->shouldReceive('countActiveForOffering')->never();
|
|
$this->gate->shouldReceive('validate')->andReturn(null);
|
|
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
|
|
$this->gate->shouldReceive('record')->once();
|
|
}
|
|
|
|
public function testEnrollInFreeClassReturnsNullPayment(): void
|
|
{
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
|
|
$this->expectSuccessfulEnrollment();
|
|
$this->payments->shouldNotReceive('createForRegistration');
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(201, $result->get_status());
|
|
self::assertSame(44, $result->get_data()['id']);
|
|
self::assertNull($result->get_data()['payment']);
|
|
}
|
|
|
|
public function testEnrollInPricedClassReturnsPaymentSummary(): void
|
|
{
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
|
|
$this->expectSuccessfulEnrollment();
|
|
$this->payments->shouldReceive('createForRegistration')
|
|
->once()
|
|
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null, null, null, 5)
|
|
->andReturn(new Payment(
|
|
studentId: 5,
|
|
instructorId: 3,
|
|
registrationType: Payment::REG_ENROLLMENT,
|
|
registrationId: 44,
|
|
amount: 120.0,
|
|
method: Payment::METHOD_ETRANSFER,
|
|
status: Payment::STATUS_PENDING,
|
|
id: 12,
|
|
));
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(
|
|
['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING],
|
|
$result->get_data()['payment']
|
|
);
|
|
}
|
|
|
|
public function testScheduledBillingEnrollmentDefersPayment(): void
|
|
{
|
|
// A monthly group class is billed later by the daily scan, so enrolment
|
|
// succeeds with no payment created now.
|
|
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 120.0, billingMode: Offering::BILLING_MONTHLY, id: 8);
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
|
$this->expectSuccessfulEnrollment();
|
|
$this->payments->shouldNotReceive('createForRegistration');
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(201, $result->get_status());
|
|
self::assertNull($result->get_data()['payment']);
|
|
}
|
|
|
|
public function testRejectsEnrollmentAfterExplicitDeadline(): void
|
|
{
|
|
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
|
|
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8);
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
|
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
|
$this->enrollments->shouldReceive('insert')->never();
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('enrollment_closed', $result->get_error_code());
|
|
self::assertSame(403, $result->error_data['enrollment_closed']['status']);
|
|
}
|
|
|
|
public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void
|
|
{
|
|
// No explicit deadline, so it defaults to term_start (the first class day),
|
|
// which is in the past relative to the stubbed 2026-07-24 "today".
|
|
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8);
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
|
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
|
$this->enrollments->shouldReceive('insert')->never();
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('enrollment_closed', $result->get_error_code());
|
|
}
|
|
|
|
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
|
|
{
|
|
$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->enrollments->shouldReceive('insert')->never();
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('invite_required', $result->get_error_code());
|
|
self::assertSame(403, $result->error_data['invite_required']['status']);
|
|
}
|
|
|
|
public function testInviteOnlyClassAllowsGrantedStudentAndMarksEnrolled(): void
|
|
{
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
|
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(true);
|
|
$this->expectSuccessfulEnrollment();
|
|
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(201, $result->get_status());
|
|
}
|
|
|
|
public function testWithdrawCancelsEnrolmentAndVoidsPendingWithoutCrediting(): void
|
|
{
|
|
// No withdrawal deadline set, so withdrawal is open. current_time is 2026-07-24.
|
|
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
|
|
$this->enrollments->shouldReceive('updateStatus')->once()->with(3, Enrollment::STATUS_CANCELLED)->andReturn(true);
|
|
$this->payments->shouldReceive('voidPending')->once()->with(41);
|
|
|
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(200, $result->get_status());
|
|
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
|
|
}
|
|
|
|
public function testWithdrawRejectedAfterDeadline(): void
|
|
{
|
|
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
|
|
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', withdrawalDeadline: '2026-07-10', id: 8);
|
|
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
|
$this->enrollments->shouldReceive('updateStatus')->never();
|
|
$this->payments->shouldReceive('voidPending')->never();
|
|
|
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('withdrawal_closed', $result->get_error_code());
|
|
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
|
|
}
|
|
|
|
public function testWithdrawAnswersAnotherStudentsEnrolmentExactlyLikeAnUnknownOne(): void
|
|
{
|
|
// Enrolment belongs to student 9, but the caller is student 5. The refusal
|
|
// must match testWithdrawReturnsNotFoundForUnknownEnrolment below exactly,
|
|
// or the two answers together enumerate the studio's enrolments.
|
|
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
|
|
$this->enrollments->shouldReceive('updateStatus')->never();
|
|
|
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('not_found', $result->get_error_code());
|
|
self::assertSame(404, $result->error_data['not_found']['status']);
|
|
}
|
|
|
|
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
|
|
{
|
|
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(null);
|
|
|
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('not_found', $result->get_error_code());
|
|
self::assertSame(404, $result->error_data['not_found']['status']);
|
|
}
|
|
|
|
public function testWithdrawIsIdempotentForAlreadyCancelledEnrolment(): void
|
|
{
|
|
// Already cancelled: no status change, no deadline check, no payment void.
|
|
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_CANCELLED, null, 3));
|
|
$this->offerings->shouldReceive('findById')->never();
|
|
$this->enrollments->shouldReceive('updateStatus')->never();
|
|
$this->payments->shouldReceive('voidPending')->never();
|
|
|
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(200, $result->get_status());
|
|
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
|
|
}
|
|
|
|
/**
|
|
* The same boundary as booking: a student id the caller may not act for is
|
|
* a 403, never a silent fallback that enrols the wrong person.
|
|
*/
|
|
public function testEnrolForAStudentTheCallerDoesNotGuardIsForbidden(): void
|
|
{
|
|
$this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false);
|
|
|
|
$this->offerings->shouldNotReceive('findById');
|
|
$this->enrollments->shouldNotReceive('insert');
|
|
$this->payments->shouldNotReceive('createForRegistration');
|
|
|
|
$request = new \WP_REST_Request(['offering_id' => 8, 'student_id' => 99]);
|
|
$result = $this->endpoint->enroll($request);
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('forbidden', $result->get_error_code());
|
|
}
|
|
|
|
public function testGuardianEnrolsTheChildAndIsBilledForIt(): void
|
|
{
|
|
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
|
|
$this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
|
|
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
|
|
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 42)->andReturn(false);
|
|
$this->enrollments->shouldReceive('countActiveForOffering')->andReturn(0);
|
|
$this->gate->shouldReceive('validate')->andReturn(null);
|
|
|
|
$this->enrollments->shouldReceive('insert')
|
|
->once()
|
|
->with(Mockery::on(static fn (Enrollment $e): bool => $e->studentId === 42))
|
|
->andReturn(44);
|
|
|
|
$this->gate->shouldReceive('record')
|
|
->once()
|
|
->with(PolicyAcceptance::REG_ENROLLMENT, 44, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5);
|
|
|
|
$this->payments->shouldReceive('createForRegistration')
|
|
->once()
|
|
->with(Payment::REG_ENROLLMENT, 44, 42, 3, 120.0, 'CAD', null, null, null, 5)
|
|
->andReturn(new Payment(42, 3, Payment::REG_ENROLLMENT, 44, 120.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
|
|
|
|
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8, 'student_id' => 42]));
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
|
self::assertSame(201, $result->get_status());
|
|
}
|
|
|
|
public function testGuardianMayWithdrawTheirChild(): void
|
|
{
|
|
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
|
|
|
|
$enrollment = new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, status: Enrollment::STATUS_ACTIVE, id: 44);
|
|
$this->enrollments->shouldReceive('findById')->with(44)->andReturn($enrollment);
|
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
|
|
$this->enrollments->shouldReceive('updateStatus')->once()->with(44, Enrollment::STATUS_CANCELLED)->andReturn(true);
|
|
$this->payments->shouldReceive('voidPending')->once();
|
|
|
|
self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->withdraw(new \WP_REST_Request(['id' => 44])));
|
|
}
|
|
|
|
public function testIndexCoversTheWholeHouseholdForAGuardian(): void
|
|
{
|
|
Functions\when('current_user_can')->justReturn(false);
|
|
|
|
$this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]);
|
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([
|
|
new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, id: 44),
|
|
]);
|
|
|
|
$data = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
|
|
|
|
self::assertCount(1, $data);
|
|
self::assertSame(42, $data[0]['student_id']);
|
|
}
|
|
}
|