Files
unsupervised-scheduler/tests/Unit/Guardian/FamilyPageTest.php
T
thatguygriffandClaude Opus 5 7e2bba79fe
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
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:47:56 -03:00

279 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class FamilyPageTest extends TestCase
{
private GuardianService&Mockery\MockInterface $guardians;
private QuestionRepository&Mockery\MockInterface $questions;
private AnswerRepository&Mockery\MockInterface $answers;
private FamilyPage $page;
protected function setUp(): void
{
parent::setUp();
$this->guardians = Mockery::mock(GuardianService::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->answers = Mockery::mock(AnswerRepository::class);
$this->page = new FamilyPage($this->guardians, $this->questions, $this->answers);
$_POST = [];
$_GET = [];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('get_permalink')->justReturn('https://studio.test/family/');
// The birth-year input caps itself at the current year.
Functions\when('current_time')->justReturn('2026');
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
Functions\when('sanitize_textarea_field')->alias(static fn (string $v): string => trim($v));
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('add_query_arg')->alias(
static fn (string $key, $value, string $url): string => $url . '?' . $key . '=' . $value
);
}
protected function tearDown(): void
{
$_POST = [];
$_GET = [];
parent::tearDown();
}
/**
* A FamilyPage whose redirect is captured instead of exiting the process.
*
* @param-out string $captured
*/
private function capturingPage(?string &$captured): FamilyPage
{
$page = Mockery::mock(FamilyPage::class, [$this->guardians, $this->questions, $this->answers])
->makePartial()
->shouldAllowMockingProtectedMethods();
$page->shouldReceive('redirect')->andReturnUsing(static function (string $url) use (&$captured): void {
$captured = $url;
});
return $page;
}
private function question(int $id, bool $required): Question
{
return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id);
}
public function testLoggedOutVisitorIsOfferedALoginLink(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('wp_login_url')->justReturn('https://studio.test/wp-login.php');
$html = $this->page->render([]);
self::assertStringContainsString('log in to manage your profile', $html);
}
public function testRenderListsTheGuardiansChildren(): void
{
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('Ada', $html);
self::assertStringContainsString('2015', $html);
self::assertStringContainsString('Add a student', $html);
}
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'child_relationship' => 'Parent',
'us_answers' => [7 => 'Piano'],
];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
$this->answers->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Answer $a): bool =>
$a->questionId === 7
&& $a->studentId === 42
&& $a->registrationId === 42
&& $a->registrationType === Answer::REG_ACCOUNT
&& $a->answerValue === 'Piano'));
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=added', $captured);
}
/**
* Validating before creating is what stops a missing answer from leaving a
* half-added child behind.
*/
public function testAddRefusesAMissingRequiredAnswerBeforeCreatingTheChild(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'us_answers' => [7 => ' '],
];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldNotReceive('createChild');
$this->answers->shouldNotReceive('insert');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
{
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
$this->questions->shouldReceive('findByScope')->once()->andReturn([]);
$this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each student a name.'));
$this->answers->shouldNotReceive('insert');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testEditDelegatesToTheServiceAndRedirects(): void
{
$_POST = [
'us_family_action' => 'edit',
'child_id' => '42',
'child_name' => 'Ada L',
'child_birth_year' => '2015',
];
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=updated', $captured);
}
public function testRemoveDelegatesToTheServiceAndRedirects(): void
{
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=removed', $captured);
}
public function testRemoveRefusalIsShownRatherThanRedirected(): void
{
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->andReturn(
new \WP_Error('has_history', 'This student has lessons or enrolments on record.')
);
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAnUnrecognisedActionDoesNothing(): void
{
$_POST = ['us_family_action' => 'destroy'];
$this->guardians->shouldNotReceive('createChild');
$this->guardians->shouldNotReceive('removeChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testNoActionIsANoOp(): void
{
$this->guardians->shouldNotReceive('createChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testLoggedOutSubmissionIsIgnored(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldNotReceive('removeChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testCompletedActionRendersItsConfirmation(): void
{
$_GET = ['us_family' => 'added'];
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
self::assertStringContainsString('Student added.', $this->page->render([]));
}
}