CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
`true` as a return type is PHP 8.2, but the plugin advertises 8.1, so the family screen's two service calls fataled on the 8.1 test job while every other job passed. They now return `?\WP_Error` — null on success — which matches RegistrationGate::validate() and works on 8.1. PHPStan was analysing against whatever PHP happened to be running (8.3 in CI, newer locally), so `composer lint` was green on syntax the plugin promises not to use. It is now pinned to the supported 8.1-8.3 range, which reproduces this failure at lint time instead of three jobs later. Co-Authored-By: Claude Opus 5 <[email protected]>
297 lines
12 KiB
PHP
297 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
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;
|
|
}
|
|
);
|
|
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-04-02', '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-04-02', $this->meta[42][GuardianService::META_DOB]);
|
|
}
|
|
|
|
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'));
|
|
}
|
|
|
|
public function testCreateChildClearsAnUnparseableDateOfBirth(): void
|
|
{
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
|
|
|
$this->service->createChild(5, 'Ada', 'not-a-date');
|
|
|
|
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
|
}
|
|
|
|
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 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 testUpdateChildRenamesAndStoresTheDateOfBirth(): 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-04-02'));
|
|
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|