Files
unsupervised-scheduler/tests/Unit/Guardian/FamilyPageTest.php
T
thatguygriffandClaude Opus 5 434fe801ba
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Ask some registration questions of students only
Every account-signup question was asked of everybody who registered, on the
same terms: "school and grade" had to be put to an adult signing themselves
up, and a question a studio needed answered for each student could only be
made required by demanding it of everyone.

A question now carries an audience — everyone, or only the students someone
registers on behalf of — and its own required flag for each side, so optional
for you and required for every student you enrol is expressible. Both settings
are account-scope only: an offering asks its questions once, about the student
being booked, so there is no second audience to differ from, and an offering
question mirrors its single "required" into both columns.

Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild()
rather than the raw flags, so a students-only question can neither block the
account holder nor have an answer filed against them by a crafted post. The
family screen, which only ever adds a student, is held to the students' rule.

is_required_child arrives from dbDelta defaulting to 0, which would quietly
stop every existing required question being required of the students a
guardian registers — the case it most likely existed for. A one-time backfill
copies is_required across, guarded by its own option so a question later made
optional for students stays that way.

Closes #163

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 13:51:52 -03:00

356 lines
12 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;
}
/**
* A question required of everyone, or of nobody — the shape every question
* had before the account holder and the students could differ, and the shape
* the upgrade backfill leaves them in.
*/
private function question(int $id, bool $required): Question
{
return new Question(
offeringId: null,
label: 'Instrument',
isRequired: $required,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: $required,
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);
}
/**
* This screen only ever adds a student, so the students' required-ness is the
* one that applies: a question required of the account holder alone must not
* stop a guardian adding a child.
*/
public function testAddIsNotBlockedByAQuestionRequiredOnlyOfTheAccountHolder(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'us_answers' => [7 => ' '],
];
$question = new Question(
offeringId: null,
label: 'Instrument',
isRequired: true,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: false,
id: 7
);
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
$this->guardians->shouldReceive('createChild')->once()->andReturn(42);
// Nothing was typed, so nothing is stored — but the add went through.
$this->answers->shouldNotReceive('insert');
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=added', $captured);
}
public function testAddIsBlockedByAQuestionRequiredOnlyOfTheStudents(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'us_answers' => [7 => ''],
];
$question = new Question(
offeringId: null,
label: 'Instrument',
isRequired: false,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: true,
id: 7
);
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
$this->guardians->shouldNotReceive('createChild');
$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([]));
}
}