Files
unsupervised-scheduler/tests/Unit/Guardian/GuardianServiceTest.php
T
thatguygriffandClaude Opus 5 9ac62a6021 Collect a birth year instead of a full date of birth
Signup and the profile page now ask for a four-digit year between 1900 and
the current year. Anything else — a short year, a full date, a year in the
future — is discarded rather than stored, so a typo cannot leave a nonsense
age on the record.

The year lives in a new us_birth_year user meta rather than reusing
us_date_of_birth, which would have left one key holding two formats. The old
key is not migrated in bulk. Instead GuardianService handles it in two
halves: birthYear() falls back to the year of the old date when the new key
is absent, so a student added before this change still shows one, and
setBirthYear() deletes the old date on every save.

That deletion is what makes the fallback safe rather than merely tidy.
Without it, clearing the birth year on a student who predates the change
would leave the old date behind for the fallback to read straight back, and
the year could never be cleared at all.

Stored in user meta, so no Schema.php change and no USC_VERSION bump.

Closes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:46:27 -03:00

362 lines
14 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;
}
);
// 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]);
}
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'));
}
/**
* @dataProvider unusableBirthYears
*/
public function testCreateChildClearsAnUnusableBirthYear(string $submitted): void
{
Functions\when('wp_insert_user')->justReturn(42);
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
$this->service->createChild(5, 'Ada', $submitted);
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
}
/** @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'],
];
}
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. Without that, clearing the birth
* year on a child who predates the change would leave the old date behind for
* the fallback above to resurrect on the next read.
*/
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', ''));
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('', $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 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));
}
}