CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped
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]>
487 lines
19 KiB
PHP
487 lines
19 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
|
use Unsupervised\Schedular\Guardian\GuardianLink;
|
|
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class GuardianServiceTest extends TestCase
|
|
{
|
|
private GuardianRepository&Mockery\MockInterface $guardians;
|
|
private BookingRepository&Mockery\MockInterface $bookings;
|
|
private EnrollmentRepository&Mockery\MockInterface $enrollments;
|
|
private GuardianService $service;
|
|
|
|
/** @var array<int, array<string, string>> */
|
|
private array $meta = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->guardians = Mockery::mock(GuardianRepository::class);
|
|
$this->bookings = Mockery::mock(BookingRepository::class);
|
|
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
|
|
|
$this->service = new GuardianService($this->guardians, $this->bookings, $this->enrollments);
|
|
|
|
$meta = &$this->meta;
|
|
Functions\when('update_user_meta')->alias(
|
|
static function (int $userId, string $key, $value) use (&$meta): bool {
|
|
$meta[$userId][$key] = (string) $value;
|
|
return true;
|
|
}
|
|
);
|
|
// A regular closure, not an arrow fn: arrow functions capture by value,
|
|
// so the stub would read a snapshot of the meta taken at setUp.
|
|
Functions\when('get_user_meta')->alias(
|
|
static function (int $userId, string $key, bool $single = false) use (&$meta): string {
|
|
return $meta[$userId][$key] ?? '';
|
|
}
|
|
);
|
|
Functions\when('delete_user_meta')->alias(
|
|
static function (int $userId, string $key) use (&$meta): bool {
|
|
unset($meta[$userId][$key]);
|
|
return true;
|
|
}
|
|
);
|
|
// The birth-year range is validated against "this year", so pin it.
|
|
Functions\when('current_time')->justReturn('2026');
|
|
Functions\when('wp_generate_password')->justReturn('abc123def456');
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
|
}
|
|
|
|
private function user(int $id, string $first = '', string $last = '', string $nickname = '', string $email = ''): \WP_User
|
|
{
|
|
$user = Mockery::mock(\WP_User::class);
|
|
$user->ID = $id;
|
|
$user->first_name = $first;
|
|
$user->last_name = $last;
|
|
$user->nickname = $nickname;
|
|
$user->user_email = $email;
|
|
|
|
return $user;
|
|
}
|
|
|
|
public function testCreateChildInsertsALoginLessUserAndLinksIt(): void
|
|
{
|
|
$captured = [];
|
|
Functions\when('wp_insert_user')->alias(
|
|
static function (array $args) use (&$captured): int {
|
|
$captured = $args;
|
|
return 42;
|
|
}
|
|
);
|
|
|
|
$this->guardians->shouldReceive('insert')
|
|
->once()
|
|
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
|
|
->andReturn(7);
|
|
|
|
$result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
|
|
|
|
self::assertSame(42, $result);
|
|
self::assertSame('Ada', $captured['display_name']);
|
|
// The address is on the reserved .invalid TLD, so it can never receive mail.
|
|
self::assertStringEndsWith('@child.invalid', $captured['user_email']);
|
|
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
|
|
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
|
}
|
|
|
|
/**
|
|
* A child is a student account created by someone who is not staff, so the
|
|
* registration gate holds it on `user_register` exactly as it holds an
|
|
* anonymous signup. There is nothing here to approve — the account is never
|
|
* signed in to, and the guardian in front of us is the approval — so the
|
|
* hold has to come off, or every child a family adds lands in the studio's
|
|
* review queue.
|
|
*/
|
|
public function testCreateChildClearsTheHoldTheRegistrationGatePutsOnIt(): void
|
|
{
|
|
// Standing in for the user_register hook, which has already run by the
|
|
// time wp_insert_user() returns.
|
|
$meta = &$this->meta;
|
|
Functions\when('wp_insert_user')->alias(
|
|
static function (array $args) use (&$meta): int {
|
|
$meta[42][RegistrationStatus::META_AWAITING_APPROVAL] = '1';
|
|
|
|
return 42;
|
|
}
|
|
);
|
|
|
|
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
|
|
|
self::assertSame(42, $this->service->createChild(5, 'Ada', '2015'));
|
|
self::assertArrayNotHasKey(RegistrationStatus::META_AWAITING_APPROVAL, $this->meta[42]);
|
|
}
|
|
|
|
public function testCreateChildRejectsABlankName(): void
|
|
{
|
|
Functions\expect('wp_insert_user')->never();
|
|
|
|
$result = $this->service->createChild(5, ' ');
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
}
|
|
|
|
/**
|
|
* A child whose link could not be written would be an unreachable orphan
|
|
* account, so the user is removed again rather than left behind.
|
|
*/
|
|
public function testCreateChildDeletesTheUserWhenTheLinkFails(): void
|
|
{
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
$this->guardians->shouldReceive('insert')->once()->andReturn(0);
|
|
|
|
Functions\expect('wp_delete_user')->once()->with(42);
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada', '2015'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider unusableBirthYears
|
|
*/
|
|
public function testCreateChildRefusesAnUnusableBirthYear(string $submitted): void
|
|
{
|
|
// Refused before anything is written, so no orphan user is left behind.
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->guardians->shouldNotReceive('insert');
|
|
|
|
$result = $this->service->createChild(5, 'Ada', $submitted);
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('missing_birth_year', $result->get_error_code());
|
|
self::assertArrayNotHasKey(42, $this->meta);
|
|
}
|
|
|
|
/** @dataProvider unusableBirthYears */
|
|
public function testUpdateChildRefusesAnUnusableBirthYear(string $submitted): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
Functions\expect('wp_update_user')->never();
|
|
|
|
$result = $this->service->updateChild(5, 42, 'Ada', $submitted);
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('missing_birth_year', $result->get_error_code());
|
|
}
|
|
|
|
/** @return array<string, array{string}> */
|
|
public static function unusableBirthYears(): array
|
|
{
|
|
return [
|
|
'not a number' => ['not-a-year'],
|
|
'a full date' => ['2015-04-02'],
|
|
'too few digits' => ['15'],
|
|
'too many digits' => ['20155'],
|
|
'before 1900' => ['1899'],
|
|
'later than today' => ['2027'],
|
|
'left blank' => [''],
|
|
];
|
|
}
|
|
|
|
public function testCanActForSelfAndOwnChildOnly(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
|
|
|
|
self::assertTrue($this->service->canActFor(5, 5));
|
|
self::assertTrue($this->service->canActFor(5, 42));
|
|
self::assertFalse($this->service->canActFor(5, 99));
|
|
}
|
|
|
|
public function testCanActForRejectsNonPositiveIds(): void
|
|
{
|
|
self::assertFalse($this->service->canActFor(0, 42));
|
|
self::assertFalse($this->service->canActFor(5, 0));
|
|
}
|
|
|
|
public function testPayerForResolvesTheGuardianAndFallsBackToTheStudent(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42));
|
|
$this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null);
|
|
|
|
self::assertSame(5, $this->service->payerFor(42));
|
|
self::assertSame(9, $this->service->payerFor(9));
|
|
}
|
|
|
|
public function testHouseholdIdsCoverTheUserAndEveryChild(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([
|
|
new GuardianLink(5, 42),
|
|
new GuardianLink(5, 43),
|
|
]);
|
|
|
|
self::assertSame([5, 42, 43], $this->service->householdIds(5));
|
|
}
|
|
|
|
/**
|
|
* The order is the feature: a guardian's default selection must be a child,
|
|
* never themselves, so a lesson meant for a kid is not booked in the
|
|
* parent's name by simply not touching the picker.
|
|
*/
|
|
public function testBookableStudentsListsChildrenBeforeTheAccountHolder(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([
|
|
new GuardianLink(5, 42),
|
|
new GuardianLink(5, 43),
|
|
]);
|
|
|
|
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => match ($id) {
|
|
5 => $this->user(5, 'Grace', 'Hopper'),
|
|
42 => $this->user(42, 'Ada', 'Lovelace'),
|
|
default => $this->user(43, 'Alan', 'Turing'),
|
|
});
|
|
|
|
$students = $this->service->bookableStudents(5);
|
|
|
|
self::assertSame(['Ada Lovelace', 'Alan Turing', 'Grace Hopper'], array_column($students, 'name'));
|
|
self::assertSame([42, 43, 5], array_column($students, 'id'));
|
|
self::assertSame([false, false, true], array_column($students, 'is_self'));
|
|
}
|
|
|
|
public function testChildrenReportsTheStoredBirthYear(): void
|
|
{
|
|
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
|
|
|
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
|
|
|
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
|
}
|
|
|
|
/**
|
|
* A child added before this feature switched to a year has only the old full
|
|
* date on record, and must still show a birth year.
|
|
*/
|
|
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
|
|
{
|
|
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
|
|
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
|
|
|
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
|
}
|
|
|
|
/**
|
|
* Saving a child drops the legacy full date, so the fallback above can never
|
|
* outrank a year the guardian has since corrected by hand.
|
|
*/
|
|
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
|
|
{
|
|
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
|
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
Functions\when('wp_update_user')->justReturn(42);
|
|
|
|
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2016'));
|
|
|
|
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
|
self::assertSame('2016', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
|
|
|
// The corrected year is what is read back, not the year of the old date.
|
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
|
|
|
self::assertSame('2016', $this->service->children(5)[0]['birth_year']);
|
|
}
|
|
|
|
public function testBookableStudentsIsJustTheUserWithoutChildren(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
|
|
Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace'));
|
|
|
|
$students = $this->service->bookableStudents(9);
|
|
|
|
self::assertCount(1, $students);
|
|
self::assertTrue($students[0]['is_self']);
|
|
}
|
|
|
|
public function testContactForPrefersTheGuardian(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42));
|
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
|
|
|
self::assertSame(
|
|
['id' => 5, 'name' => 'Grace Hopper', 'email' => '[email protected]'],
|
|
$this->service->contactFor(42)
|
|
);
|
|
}
|
|
|
|
public function testContactForFallsBackToTheStudentThemselves(): void
|
|
{
|
|
$this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null);
|
|
Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace', email: '[email protected]'));
|
|
|
|
self::assertSame(
|
|
['id' => 9, 'name' => 'Ada Lovelace', 'email' => '[email protected]'],
|
|
$this->service->contactFor(9)
|
|
);
|
|
}
|
|
|
|
public function testUpdateChildRefusesAStudentTheCallerDoesNotGuard(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
|
|
Functions\expect('wp_update_user')->never();
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
|
|
}
|
|
|
|
public function testUpdateChildRenamesAndStoresTheBirthYear(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
Functions\expect('wp_update_user')
|
|
->once()
|
|
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
|
|
->andReturn(42);
|
|
|
|
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
|
|
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
|
}
|
|
|
|
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
|
|
{
|
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
|
|
|
Functions\expect('wp_update_user')
|
|
->once()
|
|
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
|
|
->andReturn(5);
|
|
|
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
|
|
|
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
|
// Saying they take lessons makes them a bookable student again.
|
|
self::assertFalse(GuardianService::isGuardianOnly(5));
|
|
}
|
|
|
|
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
|
|
{
|
|
Functions\when('wp_update_user')->justReturn(5);
|
|
|
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
|
|
|
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
|
|
}
|
|
|
|
/**
|
|
* "I only book for other people" says who books, not "forget my birth year" —
|
|
* ticking the box back on should not have cost them what was on file.
|
|
*/
|
|
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
|
|
{
|
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
|
|
|
Functions\when('wp_update_user')->justReturn(5);
|
|
|
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
|
|
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
|
}
|
|
|
|
public function testUpdateSelfRejectsABlankName(): void
|
|
{
|
|
Functions\expect('wp_update_user')->never();
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
|
|
}
|
|
|
|
/**
|
|
* The browser cannot enforce the year conditionally, so the server is the
|
|
* only thing standing between a student and a nonsense age on their record.
|
|
*
|
|
* @dataProvider unusableBirthYears
|
|
*/
|
|
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
|
|
{
|
|
Functions\expect('wp_update_user')->never();
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
|
|
}
|
|
|
|
public function testAccountHolderReportsTheirOwnDetails(): void
|
|
{
|
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
|
|
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
|
|
|
self::assertSame(
|
|
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
|
|
$this->service->accountHolder(5)
|
|
);
|
|
}
|
|
|
|
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
|
|
{
|
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
|
|
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
|
|
|
self::assertFalse($this->service->accountHolder(5)['is_student']);
|
|
}
|
|
|
|
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]);
|
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
|
|
$this->guardians->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
|
|
|
|
Functions\expect('wp_delete_user')->once()->with(42);
|
|
|
|
self::assertNull($this->service->removeChild(5, 42));
|
|
}
|
|
|
|
/**
|
|
* A child's id is referenced by lessons, payments and credits, so deleting
|
|
* one with history would orphan all of it.
|
|
*/
|
|
public function testRemoveChildRefusesOnceTheyHaveLessons(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]);
|
|
$this->guardians->shouldNotReceive('delete');
|
|
|
|
$result = $this->service->removeChild(5, 42);
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $result);
|
|
self::assertSame('has_history', $result->get_error_code());
|
|
}
|
|
|
|
public function testRemoveChildRefusesOnceTheyHaveEnrolments(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
|
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]);
|
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]);
|
|
$this->guardians->shouldNotReceive('delete');
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 42));
|
|
}
|
|
|
|
public function testRemoveChildRefusesAStudentTheCallerDoesNotGuard(): void
|
|
{
|
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
|
|
$this->guardians->shouldNotReceive('delete');
|
|
|
|
self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 99));
|
|
}
|
|
|
|
public function testIsChildReadsTheMetaFlag(): void
|
|
{
|
|
$this->meta[42][GuardianService::META_CHILD] = '1';
|
|
|
|
self::assertTrue(GuardianService::isChild(42));
|
|
self::assertFalse(GuardianService::isChild(9));
|
|
}
|
|
}
|