@@ -102,6 +70,48 @@ $renderQuestionField = static function (Question $question): void {
@@ -124,6 +134,17 @@ $renderQuestionField = static function (Question $question): void {
+
+
@@ -137,7 +158,10 @@ $renderQuestionField = static function (Question $question): void {
-
+ id . ']', 'us-reg-q-' . (int) $question->id);
+ ?>
diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php
index 68e84ff..1d1b1a8 100644
--- a/tests/Unit/Auth/RegistrationPageTest.php
+++ b/tests/Unit/Auth/RegistrationPageTest.php
@@ -10,9 +10,11 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
+use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -63,6 +65,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
+ $this->ctx['guardians'] = Mockery::mock(GuardianService::class);
$this->ctx['page'] = new RegistrationPage(
$invites,
@@ -74,6 +77,7 @@ class RegistrationPageTest extends TestCase
$questions,
$answers,
$access,
+ $this->ctx['guardians'],
);
$_POST = [];
@@ -444,6 +448,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['questions'],
$this->ctx['answers'],
$this->ctx['access'],
+ $this->ctx['guardians'],
]
)->makePartial()->shouldAllowMockingProtectedMethods();
@@ -600,4 +605,192 @@ class RegistrationPageTest extends TestCase
self::assertStringContainsString('Ask the front desk for a link.', $html);
self::assertStringNotContainsString('by invitation only', $html);
}
+
+ /**
+ * A guardian's signup creates one login-less child per filled block, links
+ * them, and records each child's answers against the child rather than the
+ * account holder — the questions describe the student, not the parent.
+ */
+ public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
+ {
+ $_POST = [
+ 'password' => 'password123',
+ 'display_name' => 'Grace',
+ 'us_is_guardian' => '1',
+ 'children' => [
+ ['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
+ ['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
+ // An untouched spare block is dropped, not rejected.
+ ['name' => ' ', 'dob' => '', 'answers' => []],
+ ],
+ ];
+
+ $this->ctx['questions']->shouldReceive('findByScope')->andReturn([
+ new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
+ ]);
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\when('wp_insert_user')->justReturn(42);
+ Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
+
+ $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101);
+ $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
+
+ $recorded = [];
+ $this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
+ static function (Answer $a) use (&$recorded): int {
+ $recorded[] = [$a->studentId, $a->answerValue];
+ return 1;
+ }
+ );
+
+ $this->ctx['invites']->shouldReceive('markAccepted')->once();
+ Functions\expect('wp_set_current_user')->once()->with(42);
+ Functions\expect('wp_set_auth_cookie')->once()->with(42);
+
+ self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false));
+ self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
+ }
+
+ public function testGuardianSignupWithNoChildrenIsRejected(): void
+ {
+ $_POST = [
+ 'password' => 'password123',
+ 'display_name' => 'Grace',
+ 'us_is_guardian' => '1',
+ 'children' => [['name' => '', 'dob' => '', 'answers' => []]],
+ ];
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\expect('wp_insert_user')->never();
+ $this->ctx['guardians']->shouldNotReceive('createChild');
+
+ $result = $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false);
+
+ self::assertStringContainsString('at least one child', $result);
+ }
+
+ /**
+ * Required per-child answers are validated before any user exists, so a
+ * missing one never leaves a half-registered family behind.
+ */
+ public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
+ {
+ $_POST = [
+ 'password' => 'password123',
+ 'display_name' => 'Grace',
+ 'us_is_guardian' => '1',
+ 'children' => [
+ ['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
+ ['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']],
+ ],
+ ];
+
+ $this->ctx['questions']->shouldReceive('findByScope')->andReturn([
+ new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
+ ]);
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\expect('wp_insert_user')->never();
+ $this->ctx['guardians']->shouldNotReceive('createChild');
+
+ self::assertStringContainsString('for each child', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false));
+ }
+
+ /**
+ * A family that half-created would leave the guardian unable to re-register
+ * and their children unconfirmed, so the whole signup is undone.
+ */
+ public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
+ {
+ $_POST = [
+ 'password' => 'password123',
+ 'display_name' => 'Grace',
+ 'us_is_guardian' => '1',
+ 'children' => [
+ ['name' => 'Ada', 'dob' => '', 'answers' => []],
+ ['name' => 'Alan', 'dob' => '', 'answers' => []],
+ ],
+ ];
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\when('wp_insert_user')->justReturn(42);
+ Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
+
+ $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101);
+ $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
+ ->andReturn(new \WP_Error('link_failed', 'Nope.'));
+
+ $deleted = [];
+ $this->ctx['guardians']->shouldReceive('deleteUser')->andReturnUsing(
+ static function (int $id) use (&$deleted): void {
+ $deleted[] = $id;
+ }
+ );
+
+ $result = $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false);
+
+ self::assertStringContainsString('Could not create the account', $result);
+ self::assertSame([101, 42], $deleted);
+ }
+
+ /**
+ * The child is who the policy binds; the guardian is who agreed. Both are
+ * recorded, which is what makes the acceptance legally meaningful.
+ */
+ public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
+ {
+ $_POST = [
+ 'password' => 'password123',
+ 'display_name' => 'Grace',
+ 'us_is_guardian' => '1',
+ 'accept' => [3],
+ 'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]],
+ ];
+
+ $version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
+ $this->ctx['policies']->shouldReceive('findForScope')->andReturn([
+ new Policy(title: 'Studio Terms', slug: 'terms', currentVersionId: 3, id: 1),
+ ]);
+ $this->ctx['versions']->shouldReceive('findById')->with(3)->andReturn($version);
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\when('wp_insert_user')->justReturn(42);
+ Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
+
+ $this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
+
+ $recorded = [];
+ $this->ctx['acceptances']->shouldReceive('insert')->andReturnUsing(
+ static function (PolicyAcceptance $a) use (&$recorded): int {
+ $recorded[] = [$a->studentId, $a->acceptedBy];
+ return 1;
+ }
+ );
+
+ $this->ctx['invites']->shouldReceive('markAccepted')->once();
+ Functions\expect('wp_set_current_user')->once();
+ Functions\expect('wp_set_auth_cookie')->once();
+
+ self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false));
+
+ // The guardian agreed for themselves as an account holder, and for the child.
+ self::assertSame([[42, 42], [101, 42]], $recorded);
+ }
+
+ public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
+ {
+ $_POST = ['password' => 'password123', 'display_name' => 'Ada'];
+
+ Functions\when('email_exists')->justReturn(false);
+ Functions\when('wp_insert_user')->justReturn(42);
+ Functions\when('is_wp_error')->justReturn(false);
+
+ $this->ctx['guardians']->shouldNotReceive('createChild');
+ $this->ctx['invites']->shouldReceive('markAccepted')->once();
+ Functions\expect('wp_set_current_user')->once();
+ Functions\expect('wp_set_auth_cookie')->once();
+
+ self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false));
+ }
}
diff --git a/tests/Unit/Auth/StudentHistoryTest.php b/tests/Unit/Auth/StudentHistoryTest.php
index ecc33c7..61de3e5 100644
--- a/tests/Unit/Auth/StudentHistoryTest.php
+++ b/tests/Unit/Auth/StudentHistoryTest.php
@@ -58,7 +58,7 @@ class StudentHistoryTest extends TestCase
public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void
{
$this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([
- new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, null, '2026-06-02 09:00:00', 1),
+ new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, ipAddress: null, acceptedAt: '2026-06-02 09:00:00', id: 1),
]);
$this->policyVersions->shouldReceive('findById')->with(9)
->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9));
@@ -177,9 +177,9 @@ class StudentHistoryTest extends TestCase
Payment::REG_LESSON,
12,
100.00,
- 'CAD',
- Payment::METHOD_CARD,
- Payment::STATUS_PAID,
+ currency: 'CAD',
+ method: Payment::METHOD_CARD,
+ status: Payment::STATUS_PAID,
taxRate: 13.0,
taxAmount: 13.00,
receiptNumber: 'USC-7',
@@ -231,7 +231,7 @@ class StudentHistoryTest extends TestCase
public function testCreditsBuildDisplayRows(): void
{
$this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([
- new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300),
+ new Credit(5, 33.00, 13.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77', status: Credit::STATUS_AVAILABLE, createdAt: '2026-07-01 09:00:00', id: 300),
]);
$rows = $this->history->credits(5);
diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php
index 266869a..9401247 100644
--- a/tests/Unit/BlockRegistrarTest.php
+++ b/tests/Unit/BlockRegistrarTest.php
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\BlockRegistrar;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
+use Unsupervised\Schedular\Guardian\FamilyPage;
/**
* Test double exposing editor-preview mode as a switch (the real detection
@@ -41,6 +42,7 @@ class BlockRegistrarTest extends TestCase
private LoginPage&Mockery\MockInterface $loginPage;
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
+ private FamilyPage&Mockery\MockInterface $familyPage;
private TestableBlockRegistrar $registrar;
protected function setUp(): void
@@ -51,6 +53,7 @@ class BlockRegistrarTest extends TestCase
$this->loginPage = Mockery::mock(LoginPage::class);
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
+ $this->familyPage = Mockery::mock(FamilyPage::class);
// Most requests are not a just-finished registration; the tests that
// exercise that path override this.
@@ -63,6 +66,7 @@ class BlockRegistrarTest extends TestCase
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
+ $this->familyPage,
);
}
@@ -74,7 +78,7 @@ class BlockRegistrarTest extends TestCase
$this->registrar->register();
}
- public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void
+ public function testRegisterBlocksRegistersAllBlocksWithAssets(): void
{
Functions\expect('wp_register_script')
->once()
@@ -111,6 +115,7 @@ class BlockRegistrarTest extends TestCase
'us-scheduler/student-login',
'us-scheduler/student-register',
'us-scheduler/group-classes',
+ 'us-scheduler/family',
],
array_keys($registered)
);
@@ -207,6 +212,7 @@ class BlockRegistrarTest extends TestCase
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
+ $this->familyPage,
);
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php
index a551aeb..cbd324c 100644
--- a/tests/Unit/Booking/BookingEndpointTest.php
+++ b/tests/Unit/Booking/BookingEndpointTest.php
@@ -11,16 +11,19 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
+use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingEndpointTest extends TestCase
{
+ private GuardianService&Mockery\MockInterface $guardians;
private AvailabilityRepository $availability;
private BookingRepository $bookings;
private OfferingRepository $offerings;
@@ -52,6 +55,14 @@ class BookingEndpointTest extends TestCase
// cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
+ $this->guardians = Mockery::mock(GuardianService::class);
+ // The default account books only for itself: no guardian link anywhere.
+ $this->guardians->shouldReceive('canActFor')
+ ->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
+ $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
+ $this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
+ $this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault();
+
$this->endpoint = new BookingEndpoint(
$this->availability,
$this->bookings,
@@ -59,6 +70,7 @@ class BookingEndpointTest extends TestCase
$this->gate,
$this->payments,
new CancellationPolicy($this->settings),
+ $this->guardians,
);
}
@@ -182,7 +194,7 @@ class BookingEndpointTest extends TestCase
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
+ ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -260,7 +272,7 @@ class BookingEndpointTest extends TestCase
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
+ ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -336,8 +348,8 @@ class BookingEndpointTest extends TestCase
// Three claimed occurrences at a per-lesson (one_time) price of 50 → 150.
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null)
- ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
+ ->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null, null, null, 5)
+ ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
@@ -375,8 +387,8 @@ class BookingEndpointTest extends TestCase
// A full_term price already covers the whole reservation.
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null)
- ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
+ ->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null, null, null, 5)
+ ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
@@ -427,8 +439,8 @@ class BookingEndpointTest extends TestCase
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
- ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
+ ->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
+ ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
@@ -657,4 +669,131 @@ class BookingEndpointTest extends TestCase
self::assertSame('Piano Lesson', $data[0]['offering_title']);
self::assertSame(60, $data[0]['duration_minutes']);
}
+
+ /**
+ * The authorisation boundary of guardian booking: without it any signed-in
+ * student could book — and bill — against any user id they cared to send.
+ */
+ public function testBookForAStudentTheCallerDoesNotGuardIsForbidden(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false);
+
+ // Rejected before anything is looked up, claimed, or charged.
+ $this->availability->shouldNotReceive('findById');
+ $this->availability->shouldNotReceive('claim');
+ $this->bookings->shouldNotReceive('insert');
+ $this->payments->shouldNotReceive('createForRegistration');
+
+ $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 99]);
+ $result = $this->endpoint->book($request);
+
+ self::assertInstanceOf(\WP_Error::class, $result);
+ self::assertSame('forbidden', $result->get_error_code());
+ }
+
+ public function testGuardianBooksTheLessonInTheChildsNameAndBillsThemselves(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
+ $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
+
+ $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
+ $this->offerings->shouldReceive('findById')->with(8)->andReturn(
+ new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
+ );
+ $this->gate->shouldReceive('validate')->andReturn(null);
+ $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
+
+ // The lesson belongs to the child…
+ $this->bookings->shouldReceive('insert')
+ ->once()
+ ->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 42))
+ ->andReturn(77);
+
+ // …the acceptance names the child but is attributed to the guardian…
+ $this->gate->shouldReceive('record')
+ ->once()
+ ->with(PolicyAcceptance::REG_LESSON, 77, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5);
+
+ // …and the charge is raised against the child but owed by the guardian.
+ $this->payments->shouldReceive('createForRegistration')
+ ->once()
+ ->with(Payment::REG_LESSON, 77, 42, 3, 50.0, 'CAD', null, null, null, 5)
+ ->andReturn(new Payment(42, 3, Payment::REG_LESSON, 77, 50.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
+
+ $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 42]);
+ $result = $this->endpoint->book($request);
+
+ self::assertInstanceOf(\WP_REST_Response::class, $result);
+ self::assertSame(201, $result->get_status());
+ }
+
+ /**
+ * Sending your own id explicitly is the same as sending none — no guardian
+ * lookup is needed to book for yourself.
+ */
+ public function testBookForYourOwnIdNeedsNoGuardianLink(): void
+ {
+ $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
+ $this->offerings->shouldReceive('findById')->with(8)->andReturn(
+ new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8)
+ );
+ $this->gate->shouldReceive('validate')->andReturn(null);
+ $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
+ $this->bookings->shouldReceive('insert')
+ ->once()
+ ->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 5))
+ ->andReturn(77);
+ $this->gate->shouldReceive('record')->once();
+ $this->bookings->shouldReceive('updateStatus')->once()->andReturn(true);
+
+ $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 5]);
+
+ self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->book($request));
+ }
+
+ public function testGuardianMayCancelTheirChildsLesson(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
+
+ $lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77);
+ $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
+ $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
+ $this->bookings->shouldReceive('updateStatus')->once()->with(77, Lesson::STATUS_CANCELLED)->andReturn(true);
+ $this->availability->shouldReceive('release')->once()->with(10)->andReturn(true);
+ $this->payments->shouldReceive('voidPending')->once();
+
+ $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
+
+ self::assertInstanceOf(\WP_REST_Response::class, $result);
+ }
+
+ public function testMyLessonsCoversTheWholeHouseholdSortedByStart(): void
+ {
+ Functions\when('current_user_can')->justReturn(false);
+
+ $this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]);
+ $this->guardians->shouldReceive('studentName')->with(5)->andReturn('Grace');
+ $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
+
+ $mine = new Lesson(slotId: 11, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 78);
+ $childs = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77);
+
+ $this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([$mine]);
+ $this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([$childs]);
+
+ // Slot 10 starts first, so the child's lesson leads the merged list.
+ $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
+ $this->availability->shouldReceive('findById')->with(11)->andReturn(new AvailabilitySlot(
+ instructorId: 3,
+ startDt: '2026-07-02 10:00:00',
+ endDt: '2026-07-02 11:00:00',
+ durationMinutes: 60,
+ id: 11,
+ ));
+
+ $data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
+
+ self::assertSame([77, 78], array_column($data, 'id'));
+ self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name'));
+ }
}
diff --git a/tests/Unit/Booking/BookingPageTest.php b/tests/Unit/Booking/BookingPageTest.php
index 0998223..ec83b69 100644
--- a/tests/Unit/Booking/BookingPageTest.php
+++ b/tests/Unit/Booking/BookingPageTest.php
@@ -4,17 +4,26 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
+use Mockery;
use Unsupervised\Schedular\Booking\BookingPage;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingPageTest extends TestCase
{
private BookingPage $page;
+ private GuardianService&Mockery\MockInterface $guardians;
protected function setUp(): void
{
parent::setUp();
- $this->page = new BookingPage();
+ $this->guardians = Mockery::mock(GuardianService::class);
+ // Most cases are a single-student account: one bookable person, no picker.
+ $this->guardians->shouldReceive('bookableStudents')->andReturn(
+ [['id' => 3, 'name' => 'Ada', 'is_self' => true]]
+ )->byDefault();
+
+ $this->page = new BookingPage($this->guardians);
}
/**
@@ -171,4 +180,35 @@ class BookingPageTest extends TestCase
self::assertSame('https://example.com/wp-login.php', $this->page->loginUrl(5));
}
+
+ /**
+ * A single-student account gets a one-entry list, which the script renders
+ * as no picker at all.
+ */
+ public function testStudentListIsEmbeddedForTheScript(): void
+ {
+ $html = $this->renderForStudent([]);
+
+ self::assertStringContainsString('data-students=', $html);
+ self::assertStringContainsString('"is_self":true', $html);
+ }
+
+ /**
+ * Children lead the embedded list, so the picker's default selection is a
+ * child rather than the parent.
+ */
+ public function testGuardianListLeadsWithChildren(): void
+ {
+ $this->guardians->shouldReceive('bookableStudents')->with(3)->andReturn([
+ ['id' => 42, 'name' => 'Ada', 'is_self' => false],
+ ['id' => 3, 'name' => 'Grace', 'is_self' => true],
+ ]);
+
+ $html = $this->renderForStudent([]);
+
+ $students = json_decode(html_entity_decode((string) preg_replace('/.*data-students="([^"]*)".*/s', '$1', $html)), true);
+
+ self::assertSame([42, 3], array_column((array) $students, 'id'));
+ self::assertFalse($students[0]['is_self']);
+ }
}
diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php
index d9e84cc..1ef22af 100644
--- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php
+++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php
@@ -9,15 +9,18 @@ use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
+use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class EnrollmentEndpointTest extends TestCase
{
+ private GuardianService&Mockery\MockInterface $guardians;
private EnrollmentRepository $enrollments;
private OfferingRepository $offerings;
private RegistrationGate $gate;
@@ -41,12 +44,19 @@ class EnrollmentEndpointTest extends TestCase
$this->payments = Mockery::mock(PaymentService::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
+ $this->guardians = Mockery::mock(GuardianService::class);
+ $this->guardians->shouldReceive('canActFor')
+ ->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
+ $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
+ $this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
+
$this->endpoint = new EnrollmentEndpoint(
$this->enrollments,
$this->offerings,
$this->gate,
$this->payments,
$this->access,
+ $this->guardians,
);
}
@@ -89,7 +99,7 @@ class EnrollmentEndpointTest extends TestCase
$this->expectSuccessfulEnrollment();
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null)
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -252,4 +262,82 @@ class EnrollmentEndpointTest extends TestCase
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
+
+ /**
+ * The same boundary as booking: a student id the caller may not act for is
+ * a 403, never a silent fallback that enrols the wrong person.
+ */
+ public function testEnrolForAStudentTheCallerDoesNotGuardIsForbidden(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false);
+
+ $this->offerings->shouldNotReceive('findById');
+ $this->enrollments->shouldNotReceive('insert');
+ $this->payments->shouldNotReceive('createForRegistration');
+
+ $request = new \WP_REST_Request(['offering_id' => 8, 'student_id' => 99]);
+ $result = $this->endpoint->enroll($request);
+
+ self::assertInstanceOf(\WP_Error::class, $result);
+ self::assertSame('forbidden', $result->get_error_code());
+ }
+
+ public function testGuardianEnrolsTheChildAndIsBilledForIt(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
+ $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
+
+ $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
+ $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 42)->andReturn(false);
+ $this->enrollments->shouldReceive('countActiveForOffering')->andReturn(0);
+ $this->gate->shouldReceive('validate')->andReturn(null);
+
+ $this->enrollments->shouldReceive('insert')
+ ->once()
+ ->with(Mockery::on(static fn (Enrollment $e): bool => $e->studentId === 42))
+ ->andReturn(44);
+
+ $this->gate->shouldReceive('record')
+ ->once()
+ ->with(PolicyAcceptance::REG_ENROLLMENT, 44, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5);
+
+ $this->payments->shouldReceive('createForRegistration')
+ ->once()
+ ->with(Payment::REG_ENROLLMENT, 44, 42, 3, 120.0, 'CAD', null, null, null, 5)
+ ->andReturn(new Payment(42, 3, Payment::REG_ENROLLMENT, 44, 120.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
+
+ $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8, 'student_id' => 42]));
+
+ self::assertInstanceOf(\WP_REST_Response::class, $result);
+ self::assertSame(201, $result->get_status());
+ }
+
+ public function testGuardianMayWithdrawTheirChild(): void
+ {
+ $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
+
+ $enrollment = new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, status: Enrollment::STATUS_ACTIVE, id: 44);
+ $this->enrollments->shouldReceive('findById')->with(44)->andReturn($enrollment);
+ $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
+ $this->enrollments->shouldReceive('updateStatus')->once()->with(44, Enrollment::STATUS_CANCELLED)->andReturn(true);
+ $this->payments->shouldReceive('voidPending')->once();
+
+ self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->withdraw(new \WP_REST_Request(['id' => 44])));
+ }
+
+ public function testIndexCoversTheWholeHouseholdForAGuardian(): void
+ {
+ Functions\when('current_user_can')->justReturn(false);
+
+ $this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]);
+ $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
+ $this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([
+ new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, id: 44),
+ ]);
+
+ $data = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
+
+ self::assertCount(1, $data);
+ self::assertSame(42, $data[0]['student_id']);
+ }
}
diff --git a/tests/Unit/GroupClass/GroupClassPageTest.php b/tests/Unit/GroupClass/GroupClassPageTest.php
index a11ce29..a67bf78 100644
--- a/tests/Unit/GroupClass/GroupClassPageTest.php
+++ b/tests/Unit/GroupClass/GroupClassPageTest.php
@@ -4,20 +4,29 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
+use Mockery;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassPageTest extends TestCase
{
private GroupClassPage $page;
+ private GuardianService&Mockery\MockInterface $guardians;
protected function setUp(): void
{
parent::setUp();
- $this->page = new GroupClassPage();
+ $this->guardians = Mockery::mock(GuardianService::class);
+ $this->guardians->shouldReceive('bookableStudents')->andReturn(
+ [['id' => 3, 'name' => 'Ada', 'is_self' => true]]
+ )->byDefault();
+
+ $this->page = new GroupClassPage($this->guardians);
Functions\when('is_user_logged_in')->justReturn(true);
+ Functions\when('get_current_user_id')->justReturn(3);
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
diff --git a/tests/Unit/Guardian/ChildLoginGateTest.php b/tests/Unit/Guardian/ChildLoginGateTest.php
new file mode 100644
index 0000000..6715a0e
--- /dev/null
+++ b/tests/Unit/Guardian/ChildLoginGateTest.php
@@ -0,0 +1,122 @@
+gate = new ChildLoginGate();
+ }
+
+ /**
+ * @param array $children User IDs flagged as child accounts.
+ */
+ private function stubChildren(array $children): void
+ {
+ Functions\when('get_user_meta')->alias(
+ static fn (int $userId, string $key, bool $single = false): string => in_array($userId, $children, true) ? '1' : ''
+ );
+ }
+
+ private function user(int $id): \WP_User
+ {
+ $user = Mockery::mock(\WP_User::class);
+ $user->ID = $id;
+
+ return $user;
+ }
+
+ public function testRegisterHooksBothFilters(): void
+ {
+ $this->gate->register();
+
+ self::assertNotFalse(Filters\has('wp_authenticate_user', [$this->gate, 'blockChildLogin']));
+ self::assertNotFalse(Filters\has('user_has_cap', [$this->gate, 'withholdBooking']));
+ }
+
+ public function testChildAccountCannotAuthenticate(): void
+ {
+ $this->stubChildren([42]);
+
+ $result = $this->gate->blockChildLogin($this->user(42));
+
+ self::assertInstanceOf(\WP_Error::class, $result);
+ self::assertSame('us_child_account', $result->get_error_code());
+ }
+
+ public function testOrdinaryStudentPassesThrough(): void
+ {
+ $this->stubChildren([42]);
+
+ $user = $this->user(9);
+
+ self::assertSame($user, $this->gate->blockChildLogin($user));
+ }
+
+ public function testAnEarlierAuthenticationErrorIsPassedThroughUntouched(): void
+ {
+ $this->stubChildren([42]);
+
+ $error = new \WP_Error('bad_password', 'Nope.');
+
+ self::assertSame($error, $this->gate->blockChildLogin($error));
+ }
+
+ public function testBookingCapabilityIsWithheldFromAChild(): void
+ {
+ $this->stubChildren([42]);
+
+ $caps = $this->gate->withholdBooking(
+ ['read' => true, RoleManager::CAP_BOOK_LESSON => true],
+ [],
+ [],
+ $this->user(42)
+ );
+
+ self::assertArrayNotHasKey(RoleManager::CAP_BOOK_LESSON, $caps);
+ self::assertTrue($caps['read']);
+ }
+
+ public function testBookingCapabilityIsLeftAloneForAnOrdinaryStudent(): void
+ {
+ $this->stubChildren([42]);
+
+ $caps = $this->gate->withholdBooking(
+ [RoleManager::CAP_BOOK_LESSON => true],
+ [],
+ [],
+ $this->user(9)
+ );
+
+ self::assertTrue($caps[RoleManager::CAP_BOOK_LESSON]);
+ }
+
+ public function testNonUserSubjectIsIgnored(): void
+ {
+ $caps = [RoleManager::CAP_BOOK_LESSON => true];
+
+ self::assertSame($caps, $this->gate->withholdBooking($caps, [], [], null));
+ }
+
+ public function testGuardianServiceIsTheSingleSourceOfTheChildFlag(): void
+ {
+ $this->stubChildren([42]);
+
+ self::assertTrue(GuardianService::isChild(42));
+ self::assertFalse(GuardianService::isChild(9));
+ }
+}
diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php
new file mode 100644
index 0000000..b7f183b
--- /dev/null
+++ b/tests/Unit/Guardian/FamilyPageTest.php
@@ -0,0 +1,276 @@
+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/');
+ 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 family', $html);
+ }
+
+ public function testRenderListsTheGuardiansChildren(): void
+ {
+ $this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
+ ['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'],
+ ]);
+ $this->questions->shouldReceive('findByScope')->andReturn([]);
+
+ $html = $this->page->render([]);
+
+ self::assertStringContainsString('Ada', $html);
+ self::assertStringContainsString('2015-04-02', $html);
+ self::assertStringContainsString('Add a child', $html);
+ }
+
+ public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
+ {
+ $_POST = [
+ 'us_family_action' => 'add',
+ 'child_name' => 'Ada',
+ 'child_dob' => '2015-04-02',
+ '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-04-02', '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 child 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_dob' => '2015-04-02',
+ ];
+
+ $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->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 child 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('Child added.', $this->page->render([]));
+ }
+}
diff --git a/tests/Unit/Guardian/GuardianLinkTest.php b/tests/Unit/Guardian/GuardianLinkTest.php
new file mode 100644
index 0000000..a1a450c
--- /dev/null
+++ b/tests/Unit/Guardian/GuardianLinkTest.php
@@ -0,0 +1,57 @@
+ '7',
+ 'guardian_id' => '5',
+ 'student_id' => '42',
+ 'relationship' => 'Parent',
+ 'created_at' => '2026-07-29 09:00:00',
+ ]);
+
+ self::assertSame(7, $link->id);
+ self::assertSame(5, $link->guardianId);
+ self::assertSame(42, $link->studentId);
+ self::assertSame('Parent', $link->relationship);
+ self::assertSame('2026-07-29 09:00:00', $link->createdAt);
+ }
+
+ public function testRelationshipDefaultsToEmptyWhenTheColumnIsNull(): void
+ {
+ $link = GuardianLink::fromRow((object) [
+ 'id' => '7',
+ 'guardian_id' => '5',
+ 'student_id' => '42',
+ 'relationship' => null,
+ 'created_at' => null,
+ ]);
+
+ self::assertSame('', $link->relationship);
+ self::assertNull($link->createdAt);
+ }
+
+ public function testToArrayExposesEveryColumn(): void
+ {
+ $link = new GuardianLink(guardianId: 5, studentId: 42, relationship: 'Parent', createdAt: '2026-07-29 09:00:00', id: 7);
+
+ self::assertSame(
+ [
+ 'id' => 7,
+ 'guardian_id' => 5,
+ 'student_id' => 42,
+ 'relationship' => 'Parent',
+ 'created_at' => '2026-07-29 09:00:00',
+ ],
+ $link->toArray()
+ );
+ }
+}
diff --git a/tests/Unit/Guardian/GuardianRepositoryTest.php b/tests/Unit/Guardian/GuardianRepositoryTest.php
new file mode 100644
index 0000000..5f198a1
--- /dev/null
+++ b/tests/Unit/Guardian/GuardianRepositoryTest.php
@@ -0,0 +1,126 @@
+db = Mockery::mock(\wpdb::class);
+ $this->db->prefix = 'wp_';
+ $this->repo = new GuardianRepository($this->db);
+
+ $this->db->shouldReceive('prepare')->andReturnUsing(
+ static fn (string $sql, ...$args): string => $sql . '|' . implode(',', $args)
+ )->byDefault();
+ }
+
+ public function testInsertStoresTheLinkAndReturnsId(): void
+ {
+ Functions\expect('current_time')->with('mysql')->andReturn('2026-07-29 09:00:00');
+
+ $this->db->shouldReceive('get_row')->once()->andReturn(null);
+ $this->db->shouldReceive('insert')
+ ->once()
+ ->with(
+ 'wp_us_guardians',
+ [
+ 'guardian_id' => 5,
+ 'student_id' => 42,
+ 'relationship' => 'Parent',
+ 'created_at' => '2026-07-29 09:00:00',
+ ],
+ ['%d', '%d', '%s', '%s']
+ );
+ $this->db->insert_id = 7;
+
+ self::assertSame(7, $this->repo->insert(new GuardianLink(5, 42, 'Parent')));
+ }
+
+ /**
+ * v1 is one guardian per child. The check lives in the repository so every
+ * caller — signup, the family screen, admin — gets it without repeating it.
+ */
+ public function testInsertRefusesAChildThatAlreadyHasAGuardian(): void
+ {
+ $this->db->shouldReceive('get_row')->once()->andReturn((object) [
+ 'id' => 1,
+ 'guardian_id' => 9,
+ 'student_id' => 42,
+ 'relationship' => '',
+ 'created_at' => '2026-07-01 09:00:00',
+ ]);
+ $this->db->shouldNotReceive('insert');
+
+ self::assertSame(0, $this->repo->insert(new GuardianLink(5, 42)));
+ }
+
+ public function testFindByStudentReturnsNullWhenTheyBookForThemselves(): void
+ {
+ $this->db->shouldReceive('get_row')->once()->andReturn(null);
+
+ self::assertNull($this->repo->findByStudent(42));
+ }
+
+ public function testFindByGuardianMapsEveryRow(): void
+ {
+ $this->db->shouldReceive('get_results')->once()->andReturn([
+ (object) ['id' => 1, 'guardian_id' => 5, 'student_id' => 42, 'relationship' => '', 'created_at' => '2026-07-01 09:00:00'],
+ (object) ['id' => 2, 'guardian_id' => 5, 'student_id' => 43, 'relationship' => '', 'created_at' => '2026-07-02 09:00:00'],
+ ]);
+
+ $links = $this->repo->findByGuardian(5);
+
+ self::assertCount(2, $links);
+ self::assertSame([42, 43], array_map(static fn (GuardianLink $l): int => $l->studentId, $links));
+ }
+
+ public function testFindByGuardianReturnsAnEmptyListWhenTheQueryReturnsNull(): void
+ {
+ $this->db->shouldReceive('get_results')->once()->andReturn(null);
+
+ self::assertSame([], $this->repo->findByGuardian(5));
+ }
+
+ public function testIsGuardianOfIsTrueOnlyForALinkedPair(): void
+ {
+ $this->db->shouldReceive('get_var')->once()->andReturn('1');
+ self::assertTrue($this->repo->isGuardianOf(5, 42));
+
+ $this->db->shouldReceive('get_var')->once()->andReturn(null);
+ self::assertFalse($this->repo->isGuardianOf(5, 99));
+ }
+
+ public function testDeleteReportsWhetherARowWasRemoved(): void
+ {
+ $this->db->shouldReceive('delete')
+ ->once()
+ ->with('wp_us_guardians', ['guardian_id' => 5, 'student_id' => 42], ['%d', '%d'])
+ ->andReturn(1);
+
+ self::assertTrue($this->repo->delete(5, 42));
+
+ $this->db->shouldReceive('delete')->once()->andReturn(0);
+
+ self::assertFalse($this->repo->delete(5, 99));
+ }
+
+ public function testCountChildrenReturnsAnInt(): void
+ {
+ $this->db->shouldReceive('get_var')->once()->andReturn('2');
+
+ self::assertSame(2, $this->repo->countChildren(5));
+ }
+}
diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php
new file mode 100644
index 0000000..805aaae
--- /dev/null
+++ b/tests/Unit/Guardian/GuardianServiceTest.php
@@ -0,0 +1,296 @@
+> */
+ 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: 'grace@example.test'));
+
+ self::assertSame(
+ ['id' => 5, 'name' => 'Grace Hopper', 'email' => 'grace@example.test'],
+ $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: 'ada@example.test'));
+
+ self::assertSame(
+ ['id' => 9, 'name' => 'Ada Lovelace', 'email' => 'ada@example.test'],
+ $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));
+ }
+}
diff --git a/tests/Unit/Payment/CreditRepositoryTest.php b/tests/Unit/Payment/CreditRepositoryTest.php
index 7579611..9dffc80 100644
--- a/tests/Unit/Payment/CreditRepositoryTest.php
+++ b/tests/Unit/Payment/CreditRepositoryTest.php
@@ -42,7 +42,7 @@ class CreditRepositoryTest extends TestCase
);
$this->db->insert_id = 300;
- $credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77');
+ $credit = new Credit(5, 33.0, 33.0, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77');
self::assertSame(300, $this->repo->insert($credit));
}
@@ -108,4 +108,67 @@ class CreditRepositoryTest extends TestCase
$this->repo->consume(5, 0.0);
}
+
+ /**
+ * The balance is keyed on the payer, not the student, so a guardian's
+ * account carries the credits every one of their children earned.
+ */
+ public function testAvailableBalanceQueriesThePayer(): void
+ {
+ $this->db->shouldReceive('prepare')
+ ->once()
+ ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
+ ->andReturn('sql');
+ $this->db->shouldReceive('get_var')->once()->with('sql')->andReturn('60.00');
+
+ self::assertSame(60.0, $this->repo->availableBalance(5));
+ }
+
+ public function testFindAvailableByPayerReturnsCreditsOldestFirst(): void
+ {
+ $this->db->shouldReceive('prepare')
+ ->once()
+ ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
+ ->andReturn('sql');
+ $this->db->shouldReceive('get_results')->once()->with('sql')->andReturn([
+ (object) ['id' => '1', 'student_id' => '42', 'payer_id' => '5', 'amount' => '10.00', 'remaining' => '10.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-07-01 09:00:00', 'updated_at' => null],
+ ]);
+
+ $credits = $this->repo->findAvailableByPayer(5);
+
+ self::assertCount(1, $credits);
+ self::assertSame(42, $credits[0]->studentId);
+ self::assertSame(5, $credits[0]->payerId);
+ }
+
+ /**
+ * Rows written before guardian accounts existed carry payer_id 0; the
+ * installer points them at the student who was always the payer.
+ */
+ public function testBackfillPayerIdsPointsLegacyRowsAtTheStudent(): void
+ {
+ $this->db->shouldReceive('prepare')
+ ->once()
+ ->with('UPDATE %i SET payer_id = student_id WHERE payer_id = 0', 'wp_us_credits')
+ ->andReturn('sql');
+ $this->db->shouldReceive('query')->once()->with('sql');
+
+ $this->repo->backfillPayerIds();
+ }
+
+ public function testInsertDefaultsThePayerToTheStudent(): void
+ {
+ Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
+
+ $this->db->shouldReceive('insert')
+ ->once()
+ ->with(
+ 'wp_us_credits',
+ Mockery::on(static fn (array $d): bool => $d['student_id'] === 5 && $d['payer_id'] === 5),
+ Mockery::type('array')
+ );
+ $this->db->insert_id = 301;
+
+ self::assertSame(301, $this->repo->insert(new Credit(5, 10.0, 10.0)));
+ }
}
diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php
index 301a7cc..490fd24 100644
--- a/tests/Unit/Payment/PaymentServiceTest.php
+++ b/tests/Unit/Payment/PaymentServiceTest.php
@@ -65,7 +65,7 @@ class PaymentServiceTest extends TestCase
private function payment(string $method, string $status, int $id): Payment
{
- return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', $method, $status, id: $id);
+ return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: $method, status: $status, id: $id);
}
public function testFreeRegistrationCreatesNoPayment(): void
@@ -85,7 +85,7 @@ class PaymentServiceTest extends TestCase
{
// A scheduled (weekly/monthly) payment can cover several lessons and may be
// collected: cancelling one lesson must never void it or trigger a rebill.
- $scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
+ $scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
$this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled);
$this->payments->shouldNotReceive('updateStatus');
@@ -252,7 +252,7 @@ class PaymentServiceTest extends TestCase
public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void
{
- $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91);
+ $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment);
$this->stripe->shouldNotReceive('createIntent');
@@ -348,7 +348,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
{
// A paid single-lesson payment: the whole total (incl. tax) is credited.
- $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
+ $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
@@ -361,7 +361,7 @@ class PaymentServiceTest extends TestCase
&& $c->sourceLessonId === 77))
->andReturn(300);
$this->credits->shouldReceive('findById')->with(300)->andReturn(
- new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300)
+ new Credit(5, 33.00, 33.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, id: 300)
);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77);
@@ -371,7 +371,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void
{
// A monthly scheduled charge covering 3 lessons: one cancellation credits a third.
- $paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
+ $paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
@@ -380,7 +380,7 @@ class PaymentServiceTest extends TestCase
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(301);
- $this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301));
+ $this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 202, id: 301));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
@@ -388,7 +388,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSkipsUnpaidPayment(): void
{
- $pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12);
+ $pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($pending);
$this->credits->shouldNotReceive('insert');
@@ -406,7 +406,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSkipsAlreadyCredited(): void
{
- $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
+ $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true);
@@ -420,7 +420,7 @@ class PaymentServiceTest extends TestCase
{
// A non-anchor series lesson has no payment_id of its own; the anchor's
// upfront (unscheduled) payment covers the whole 4-lesson series.
- $anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
+ $anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment);
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
@@ -430,7 +430,7 @@ class PaymentServiceTest extends TestCase
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(302);
- $this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302));
+ $this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 43, id: 302));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
@@ -487,7 +487,7 @@ class PaymentServiceTest extends TestCase
private function pending(int $id, float $amount): Payment
{
- return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
+ return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
}
private function intentEvent(string $type, string $intentId): \Stripe\Event
@@ -496,4 +496,85 @@ class PaymentServiceTest extends TestCase
return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]);
}
+
+ /**
+ * The billing method resolves against the payer, so comping or card-billing a
+ * family is one setting on the guardian rather than one per child.
+ */
+ public function testCreateForRegistrationResolvesTheBillingMethodAgainstThePayer(): void
+ {
+ $this->resolver->shouldReceive('resolve')->once()->with(5)->andReturn(Payment::METHOD_ETRANSFER);
+ $this->settings->shouldReceive('etransferEmail')->andReturn('');
+ $this->settings->shouldReceive('hstRate')->andReturn(0.0);
+
+ $this->payments->shouldReceive('insert')
+ ->once()
+ ->with(Mockery::on(static fn (Payment $p): bool => $p->studentId === 42 && $p->payerId === 5))
+ ->andReturn(90);
+ $this->bookings->shouldReceive('setPaymentId')->once();
+ $this->payments->shouldReceive('findById')->with(90)->andReturn(
+ new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, id: 90)
+ );
+
+ $payment = $this->service->createForRegistration(Payment::REG_LESSON, 12, 42, 3, 35.00, 'CAD', payerId: 5);
+
+ self::assertSame(5, $payment?->payerId);
+ }
+
+ /**
+ * A credit earned by a child lands on the guardian's balance, so one child's
+ * cancellation can settle a sibling's next charge.
+ */
+ public function testCancelledChildLessonCreditsTheGuardiansBalance(): void
+ {
+ $lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, paymentId: 12, id: 77);
+ $paid = new Payment(42, 3, Payment::REG_LESSON, 77, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
+
+ $this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
+ $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
+ $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
+
+ $this->credits->shouldReceive('insert')
+ ->once()
+ ->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 42 && $c->payerId === 5 && $c->amount === 30.0))
+ ->andReturn(300);
+ $this->credits->shouldReceive('findById')->with(300)->andReturn(
+ new Credit(42, 30.00, 30.00, payerId: 5, id: 300)
+ );
+
+ self::assertNotNull($this->service->creditForCancelledLesson($lesson));
+ }
+
+ public function testApplyCreditsDrawsDownThePayersBalanceAcrossChildrensCharges(): void
+ {
+ $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(60.0);
+
+ $adasCharge = new Payment(42, 3, Payment::REG_LESSON, 12, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 91);
+ $alansCharge = new Payment(43, 3, Payment::REG_LESSON, 13, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 92);
+
+ $this->payments->shouldReceive('addCreditApplied')->once()->with(91, 30.0);
+ $this->payments->shouldReceive('addCreditApplied')->once()->with(92, 30.0);
+ $this->payments->shouldReceive('markPaid')->twice();
+ $this->bookings->shouldReceive('findById')->andReturn(null);
+ $this->bookings->shouldReceive('updateStatus')->twice();
+ $this->credits->shouldReceive('consume')->once()->with(5, 60.0);
+
+ $applied = $this->service->applyCredits(5, [$adasCharge, $alansCharge]);
+
+ self::assertSame([91 => 30.0, 92 => 30.0], $applied);
+ }
+
+ /**
+ * A guardian paying for their child's lesson must reach the payment step;
+ * anyone else must not.
+ */
+ public function testCreateIntentIsAllowedForBothTheStudentAndTheirPayer(): void
+ {
+ $payment = new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 91);
+ $this->payments->shouldReceive('findByRegistration')->andReturn($payment);
+
+ self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 42));
+ self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
+ self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 99));
+ }
}
diff --git a/tests/Unit/Payment/ScheduledBillingRunnerTest.php b/tests/Unit/Payment/ScheduledBillingRunnerTest.php
index b003947..f233f03 100644
--- a/tests/Unit/Payment/ScheduledBillingRunnerTest.php
+++ b/tests/Unit/Payment/ScheduledBillingRunnerTest.php
@@ -8,6 +8,7 @@ use Mockery;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
+use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
@@ -18,6 +19,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class ScheduledBillingRunnerTest extends TestCase
{
+ private GuardianService&Mockery\MockInterface $guardians;
private PaymentService $payments;
private BookingRepository $bookings;
private EnrollmentRepository $enrollments;
@@ -49,12 +51,17 @@ class ScheduledBillingRunnerTest extends TestCase
$student->user_email = 'a@b.test';
Functions\when('get_userdata')->justReturn($student);
+ $this->guardians = Mockery::mock(GuardianService::class);
+ $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
+ $this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault();
+
$this->runner = new ScheduledBillingRunner(
$this->payments,
$this->bookings,
$this->enrollments,
$this->offerings,
- $this->mailer
+ $this->mailer,
+ $this->guardians
);
}
@@ -65,7 +72,7 @@ class ScheduledBillingRunnerTest extends TestCase
private function pending(int $id, string $due): Payment
{
- return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id);
+ return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: $due, id: $id);
}
private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object
@@ -92,7 +99,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', 'pay@studio.test', '2026-07-14', '2026-07-15')
+ ->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', 'pay@studio.test', '2026-07-14', '2026-07-15', 5)
->andReturn($this->pending(500, '2026-07-14'));
$this->mailer->shouldReceive('send')->once();
@@ -124,7 +131,7 @@ class ScheduledBillingRunnerTest extends TestCase
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', 'pay@studio.test', '2026-07-01', '2026-07')
+ ->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', 'pay@studio.test', '2026-07-01', '2026-07', 5)
->andReturn($this->pending(600, '2026-07-01'));
// The other two lessons are pointed at the same payment so they are not re-billed.
@@ -158,11 +165,11 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07')
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07', 5)
->andReturn($this->pending(700, '2026-07-06'));
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5)
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
@@ -181,7 +188,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5)
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
@@ -205,7 +212,7 @@ class ScheduledBillingRunnerTest extends TestCase
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5)
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
@@ -227,7 +234,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
- ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
+ ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5)
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
@@ -240,7 +247,7 @@ class ScheduledBillingRunnerTest extends TestCase
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
// A comp student's payment comes back paid — no due notice should be sent.
- $comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_COMP, Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
+ $comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_COMP, status: Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($comp);
$this->mailer->shouldNotReceive('send');
@@ -333,4 +340,92 @@ class ScheduledBillingRunnerTest extends TestCase
id: 9,
);
}
+
+ /**
+ * Two children billed on the same day belong to one payer, so the guardian
+ * gets a single notice covering both — not one email per child — and each
+ * line names whose lesson it is.
+ */
+ public function testAGuardiansChildrenShareOneNoticeWithNamedLines(): void
+ {
+ $this->now('2026-07-15 09:00:00');
+
+ $adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
+ $alans = $this->lessonRow(102, Offering::BILLING_WEEKLY, '2026-07-15 19:00:00', 35.0);
+ $adas->student_id = '42';
+ $alans->student_id = '43';
+
+ $this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas, $alans]);
+
+ $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
+ $this->guardians->shouldReceive('payerFor')->with(43)->andReturn(5);
+ $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
+ $this->guardians->shouldReceive('studentName')->with(43)->andReturn('Alan');
+
+ $this->payments->shouldReceive('createForRegistration')
+ ->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-14'));
+
+ // One send, two lines, each prefixed with the child it is for.
+ $this->mailer->shouldReceive('send')
+ ->once()
+ ->with(
+ Mockery::type(\WP_User::class),
+ Mockery::on(static function (array $items): bool {
+ return count($items) === 2
+ && str_starts_with((string) $items[0]['label'], 'Ada: ')
+ && str_starts_with((string) $items[1]['label'], 'Alan: ');
+ }),
+ Mockery::type('string'),
+ 0.0
+ );
+
+ $this->runner->run();
+ }
+
+ /**
+ * A student who pays for themselves gets the plain label — prefixing every
+ * line with their own name would be noise.
+ */
+ public function testAStudentPayingForThemselvesGetsAnUnprefixedLabel(): void
+ {
+ $this->now('2026-07-15 09:00:00');
+ $this->bookings->shouldReceive('findUnbilledScheduledLessons')
+ ->andReturn([$this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0)]);
+
+ $this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14'));
+
+ $this->mailer->shouldReceive('send')
+ ->once()
+ ->with(
+ Mockery::type(\WP_User::class),
+ Mockery::on(static fn (array $items): bool => str_starts_with((string) $items[0]['label'], 'Piano — ')),
+ Mockery::type('string'),
+ 0.0
+ );
+
+ $this->runner->run();
+ }
+
+ /**
+ * The family balance is drawn against the payer, so a credit one child
+ * earned can settle a sibling's charge.
+ */
+ public function testCreditsAreAppliedAgainstThePayerNotEachStudent(): void
+ {
+ $this->now('2026-07-15 09:00:00');
+
+ $adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
+ $adas->student_id = '42';
+
+ $this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas]);
+ $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
+ $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
+
+ $this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14'));
+ $this->payments->shouldReceive('applyCredits')->once()->with(5, Mockery::type('array'))->andReturn([]);
+
+ $this->mailer->shouldReceive('send')->once();
+
+ $this->runner->run();
+ }
}
diff --git a/tests/Unit/Payment/StripeGatewayTest.php b/tests/Unit/Payment/StripeGatewayTest.php
index d908bfa..af54010 100644
--- a/tests/Unit/Payment/StripeGatewayTest.php
+++ b/tests/Unit/Payment/StripeGatewayTest.php
@@ -29,7 +29,7 @@ class StripeGatewayTest extends TestCase
private function payment(): Payment
{
- return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_CARD, Payment::STATUS_PENDING, id: 90);
+ return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_CARD, status: Payment::STATUS_PENDING, id: 90);
}
public function testCreateIntentReturnsNullWhenNotConfigured(): void
diff --git a/tests/Unit/Policy/AcceptanceRepositoryTest.php b/tests/Unit/Policy/AcceptanceRepositoryTest.php
index 992fe4d..c0bc48d 100644
--- a/tests/Unit/Policy/AcceptanceRepositoryTest.php
+++ b/tests/Unit/Policy/AcceptanceRepositoryTest.php
@@ -36,13 +36,15 @@ class AcceptanceRepositoryTest extends TestCase
&& $d['student_id'] === 5
&& $d['registration_type'] === PolicyAcceptance::REG_LESSON
&& $d['registration_id'] === 12
+ // No explicit acceptor: the student agreed for themselves.
+ && $d['accepted_by'] === 5
&& $d['ip_address'] === '203.0.113.7';
}),
- ['%d', '%d', '%s', '%d', '%s', '%s']
+ ['%d', '%d', '%d', '%s', '%d', '%s', '%s']
);
$this->db->insert_id = 1;
- $acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, '203.0.113.7');
+ $acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, ipAddress: '203.0.113.7');
self::assertSame(1, $this->repo->insert($acceptance));
}
diff --git a/tests/Unit/ShortcodeRegistrarTest.php b/tests/Unit/ShortcodeRegistrarTest.php
index 1a91856..790ff17 100644
--- a/tests/Unit/ShortcodeRegistrarTest.php
+++ b/tests/Unit/ShortcodeRegistrarTest.php
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
+use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\ShortcodeRegistrar;
class ShortcodeRegistrarTest extends TestCase
@@ -18,6 +19,7 @@ class ShortcodeRegistrarTest extends TestCase
private LoginPage&Mockery\MockInterface $loginPage;
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
+ private FamilyPage&Mockery\MockInterface $familyPage;
private ShortcodeRegistrar $registrar;
/** @var array */
@@ -34,12 +36,14 @@ class ShortcodeRegistrarTest extends TestCase
$this->loginPage = Mockery::mock(LoginPage::class);
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
+ $this->familyPage = Mockery::mock(FamilyPage::class);
$this->registrar = new ShortcodeRegistrar(
$this->bookingPage,
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
+ $this->familyPage,
);
$shortcodes = &$this->shortcodes;
@@ -50,7 +54,7 @@ class ShortcodeRegistrarTest extends TestCase
);
}
- public function testRegisterAddsAllFourShortcodesAndHooks(): void
+ public function testRegisterAddsAllShortcodesAndHooks(): void
{
Actions\expectAdded('template_redirect')
->once()
@@ -62,7 +66,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrar->register();
self::assertSame(
- ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes'],
+ ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'],
array_keys($this->shortcodes)
);
}
@@ -97,8 +101,8 @@ class ShortcodeRegistrarTest extends TestCase
$scripts = $this->captureEnqueuedAssets();
self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']);
- self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler']);
- self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler-group']);
+ self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler']);
+ self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler-group']);
}
/**
diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php
index 2424b68..8bbe7ab 100644
--- a/unsupervised-schedular.php
+++ b/unsupervised-schedular.php
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
- * Version: 1.2.5
+ * Version: 1.3.0
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
-define('USC_VERSION', '1.2.5');
+define('USC_VERSION', '1.3.0');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));