Let the studio book lessons and record intake collected elsewhere
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Two related gaps, closed together because the second is created by the first. A private lesson could only be booked by the student or their guardian, so a booking taken over the phone had no way in — where group classes have had "Add students directly" all along. "Book a lesson for a student" is now a panel on Scheduler and My Lessons: student, open time, lesson type, with weekly term reservations and a no-charge option for make-up lessons. The booking core is extracted to Booking\LessonBooker and shared with POST /bookings, so the two paths cannot drift on offering rules, slot claiming, or billing. That leaves a registration with no intake answers and no policy acceptances, because nobody was at a keyboard to give them — already true of every directly added group-class student. Ticking the boxes on a student's behalf would be an audit trail that says something untrue, so instead the answers are collected another way and recorded afterwards, from a lesson's or an enrolment's detail page. Every recording must say how it was collected, which is stamped on each row along with who typed it and shown in a new "How it was given" column: a policy ticked online and one transcribed from paper must never look alike. Only staff-made registrations qualify (us_lessons.booked_by, us_group_enrollments.enrolled_by) — one the student made already holds their own answers. Only what is still missing can be recorded, re-checked at write time, so a stale or double-posted form cannot duplicate or overwrite. No IP is stored for a transcription, and accepted_by stays the student while recorded_by names the staff member. Intake is now generic over Registration\IntakeSubject, which Lesson and Enrollment both implement; LessonDetail became Registration\IntakeAudit and is shared by both detail views rather than duplicated. Closes #182 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
This commit is contained in:
@@ -98,7 +98,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersResolveQuestionLabels(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', 1),
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
->andReturn(new Question(1, 'Experience level', id: 4));
|
||||
@@ -120,7 +120,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersFallBackWhenQuestionIsGone(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, 1),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)->andReturn(null);
|
||||
|
||||
@@ -133,8 +133,8 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersExcludeAccountScopeAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1),
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', id: 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
// Only the booking-scoped answer is resolved; the account answer is dropped.
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
@@ -150,7 +150,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1),
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([
|
||||
new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4),
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
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\Tests\Unit\TestCase;
|
||||
|
||||
class AdminBookingTest extends TestCase
|
||||
{
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private PaymentService&Mockery\MockInterface $payments;
|
||||
private GuardianService&Mockery\MockInterface $guardians;
|
||||
private AdminBooking $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
Functions\when('mysql2date')->alias(
|
||||
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||
);
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\when('get_users')->justReturn([]);
|
||||
// Everyone offered in the picker can book; the guard is exercised on its own.
|
||||
Functions\when('user_can')->justReturn(true);
|
||||
// The staff member doing the booking; stamped on the lesson as booked_by.
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
|
||||
|
||||
// The real booker over mocked repositories: an admin booking must go
|
||||
// through exactly the machinery a student's own booking does.
|
||||
$this->admin = new AdminBooking(
|
||||
$this->availability,
|
||||
$this->offerings,
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians)
|
||||
);
|
||||
}
|
||||
|
||||
public function testBooksASingleLessonAndRaisesAPendingPayment(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 7 === $l->slotId
|
||||
&& 42 === $l->studentId
|
||||
&& 9 === $l->instructorId
|
||||
&& 3 === $l->offeringId
|
||||
&& Lesson::RECURRENCE_SINGLE === $l->recurrence
|
||||
&& 'Booked by phone' === $l->notes
|
||||
// Stamped with who booked it, which is what later lets the studio
|
||||
// record the intake it never had a chance to collect.
|
||||
&& 3 === $l->bookedBy
|
||||
))->andReturn(100);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('30 min piano', $notice);
|
||||
self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice);
|
||||
self::assertStringContainsString('pending payment', $notice);
|
||||
}
|
||||
|
||||
public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
|
||||
|
||||
// The whole point of the no-charge tick: a priced offering raises nothing.
|
||||
$this->payments->shouldReceive('createForRegistration')->never();
|
||||
$this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true);
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('Nothing is owed', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void
|
||||
{
|
||||
$slot = $this->slot(recurrenceGroup: 55);
|
||||
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([
|
||||
$slot,
|
||||
$this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55),
|
||||
$this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55),
|
||||
]);
|
||||
$this->availability->shouldReceive('claim')->times(3)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insertSeries')->once()
|
||||
->with(Mockery::type(Lesson::class), [7, 8, 9])
|
||||
->andReturn([100, 101, 102]);
|
||||
|
||||
// A per-lesson (one_time) price is owed once per occurrence claimed.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('3 weekly lessons', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
|
||||
// Nothing is claimed or written: the staff member asked for a term and is
|
||||
// told they cannot have one, rather than silently getting one lesson.
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
$this->bookings->shouldReceive('insert')->never();
|
||||
$this->bookings->shouldReceive('insertSeries')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_weekly', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testInstructorScopeRefusesAnotherInstructorsTime(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
// Slot belongs to instructor 9; My Lessons is scoped to instructor 4.
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_slot', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAlreadyBookedTimeIsRefused(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('slot_taken', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testSomeoneWhoCannotBookLessonsIsRefused(): void
|
||||
{
|
||||
Functions\when('user_can')->justReturn(false);
|
||||
$this->availability->shouldReceive('findById')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_student', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_mismatch', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 3 === $l->offeringId
|
||||
))->andReturn(100);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||
|
||||
self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''));
|
||||
}
|
||||
|
||||
public function testAGeneralTimeNeedsALessonTypeChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')
|
||||
->once()
|
||||
->with(9, 0, 0, '', '2026-07-27 10:00:00')
|
||||
->andReturn([$this->slot(recurrenceGroup: 55)]);
|
||||
$this->offerings->shouldReceive('findAll')
|
||||
->once()
|
||||
->with(9, Offering::KIND_PRIVATE_LESSON, true)
|
||||
->andReturn([$this->offering()]);
|
||||
|
||||
$data = $this->admin->formData(9);
|
||||
|
||||
self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']);
|
||||
self::assertSame(1, count($data['slots']));
|
||||
self::assertTrue($data['slots'][0]['weekly']);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]);
|
||||
$this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe'));
|
||||
|
||||
$data = $this->admin->formData(0);
|
||||
|
||||
self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('30 min piano', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
private function slot(
|
||||
int $id = 7,
|
||||
string $startDt = '2026-07-01 10:00:00',
|
||||
bool $isBooked = false,
|
||||
?int $offeringId = null,
|
||||
?int $recurrenceGroup = null
|
||||
): AvailabilitySlot {
|
||||
return new AvailabilitySlot(
|
||||
instructorId: 9,
|
||||
startDt: $startDt,
|
||||
endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800),
|
||||
durationMinutes: 30,
|
||||
offeringId: $offeringId,
|
||||
isBooked: $isBooked,
|
||||
recurrenceGroup: $recurrenceGroup,
|
||||
id: $id,
|
||||
);
|
||||
}
|
||||
|
||||
private function offering(): Offering
|
||||
{
|
||||
return new Offering(
|
||||
instructorId: 9,
|
||||
kind: Offering::KIND_PRIVATE_LESSON,
|
||||
title: '30 min piano',
|
||||
price: 40.0,
|
||||
durationMinutes: 30,
|
||||
isActive: true,
|
||||
id: 3,
|
||||
);
|
||||
}
|
||||
|
||||
private function pendingPayment(): Payment
|
||||
{
|
||||
return new Payment(
|
||||
studentId: 42,
|
||||
instructorId: 9,
|
||||
registrationType: Payment::REG_LESSON,
|
||||
registrationId: 100,
|
||||
amount: 40.0,
|
||||
status: Payment::STATUS_PENDING,
|
||||
id: 500,
|
||||
);
|
||||
}
|
||||
|
||||
private function user(int $id, string $name): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = $id;
|
||||
$user->first_name = '';
|
||||
$user->last_name = '';
|
||||
$user->nickname = $name;
|
||||
$user->display_name = $name;
|
||||
$user->user_login = 'jane';
|
||||
$user->user_email = '[email protected]';
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
@@ -76,6 +77,10 @@ class BookingEndpointTest extends TestCase
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
// The real booker over the same mocked repositories: these tests are
|
||||
// about what a booking does end to end, and the booker is where most
|
||||
// of that now lives.
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians),
|
||||
new CancellationPolicy($this->settings),
|
||||
$this->guardians,
|
||||
$this->sessions,
|
||||
|
||||
@@ -36,9 +36,11 @@ class BookingRepositoryTest extends TestCase
|
||||
&& $data['student_id'] === 5
|
||||
&& $data['offering_id'] === 7
|
||||
&& $data['recurrence'] === Lesson::RECURRENCE_SINGLE
|
||||
&& $data['status'] === Lesson::STATUS_PENDING;
|
||||
&& $data['status'] === Lesson::STATUS_PENDING
|
||||
// Booked through the student-facing flow: no staff booker.
|
||||
&& $data['booked_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s']
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
$this->db->insert_id = 77;
|
||||
|
||||
@@ -9,8 +9,11 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
@@ -21,7 +24,9 @@ class LessonControllerTest extends TestCase
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private IntakeAudit&Mockery\MockInterface $detail;
|
||||
private AdminBooking&Mockery\MockInterface $adminBooking;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -32,8 +37,17 @@ class LessonControllerTest extends TestCase
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
$this->detail = Mockery::mock(IntakeAudit::class);
|
||||
$this->adminBooking = Mockery::mock(AdminBooking::class);
|
||||
// The book-for-a-student panel has its own tests; here it is an empty form.
|
||||
$this->adminBooking->shouldReceive('formData')
|
||||
->andReturn(['students' => [], 'offerings' => [], 'slots' => []])->byDefault();
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
// Most lessons here were booked by the student, so nothing is recordable;
|
||||
// the intake tests set up their own staff-booked lesson.
|
||||
$this->intake->shouldReceive('pending')
|
||||
->andReturn(['questions' => [], 'policies' => []])->byDefault();
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail, $this->adminBooking, $this->intake);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -50,6 +64,17 @@ class LessonControllerTest extends TestCase
|
||||
Functions\when('current_time')->justReturn('2026-07-06');
|
||||
Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// The form-post tests fill $_POST; left behind it makes every later test
|
||||
// in the suite look like a form submission.
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void
|
||||
@@ -236,10 +261,10 @@ class LessonControllerTest extends TestCase
|
||||
// The lesson itself is handed over, so the presenter can follow a series
|
||||
// occurrence back to the anchor its answers and acceptances hang off.
|
||||
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
@@ -274,6 +299,215 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
public function testTheBookForAStudentPanelOffersTheOpenTimesAndStudents(): void
|
||||
{
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(0)->andReturn([
|
||||
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
|
||||
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
|
||||
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
|
||||
]);
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Book a lesson for a student', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $html);
|
||||
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
|
||||
}
|
||||
|
||||
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
// Scope 0: the studio Scheduler may book any instructor's open time.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 0)
|
||||
->andReturn('Booked Ada Lovelace into 30 min piano.');
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Booked Ada Lovelace into 30 min piano.', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
self::assertStringNotContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
public function testAnInstructorBooksOnlyAgainstTheirOwnTimes(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
Functions\when('get_current_user_id')->justReturn(9);
|
||||
|
||||
// Scope 9: My Lessons must not reach another instructor's schedule.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 9)
|
||||
->andReturn('Booked.');
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(9)->andReturn(
|
||||
['students' => [], 'offerings' => [], 'slots' => []]
|
||||
);
|
||||
$this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(9)->andReturn([]);
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('Booked.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedBookingShowsWhyAndReopensTheForm(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
$this->adminBooking->shouldReceive('book')->once()
|
||||
->andReturn(new \WP_Error('slot_taken', 'That time has already been booked.'));
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('That time has already been booked.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
// The panel is a collapsed <details>; an error opens it so the message is
|
||||
// not hidden behind the summary.
|
||||
self::assertStringContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
/** Fill $_POST as the book-for-a-student form does. */
|
||||
private function postBooking(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'book_for_student',
|
||||
'student_id' => '42',
|
||||
'slot_id' => '7',
|
||||
'offering_id' => '3',
|
||||
'recurrence_weekly' => '1',
|
||||
'no_charge' => '1',
|
||||
'notes' => 'Make-up lesson',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
}
|
||||
|
||||
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
|
||||
// booked_by 7: the studio booked this one, so its intake can be recorded.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($lesson)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => true]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
self::assertStringContainsString('On a signed paper form', $html);
|
||||
}
|
||||
|
||||
public function testALessonTheStudentBookedOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
// booked_by 0: the student booked it and gave their own answers.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
// Not even asked what is outstanding — the form is not on offer at all.
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->render());
|
||||
}
|
||||
|
||||
public function testSubmittedIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'paper',
|
||||
'collected_note' => 'Filed in the studio binder',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($lesson, [9 => 'Nut allergy'], [6], 'paper', 'Filed in the studio binder', 7)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: On a signed paper form');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// Nothing left outstanding, so the form gives way to a plain statement.
|
||||
self::assertStringContainsString('Everything has been recorded for this booking.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedRecordingSaysWhy(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'collected_via' => 'other',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [],
|
||||
]);
|
||||
$this->intake->shouldReceive('record')->once()
|
||||
->andReturn(new \WP_Error('collection_note_required', 'Say how these were collected.'));
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Say how these were collected.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
}
|
||||
|
||||
/** The lookups the detail view makes for one lesson, with an empty audit trail. */
|
||||
private function expectDetail(Lesson $lesson): void
|
||||
{
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
|
||||
$this->detail->shouldReceive('answers')->with($lesson)->andReturn([]);
|
||||
$this->detail->shouldReceive('acceptances')->with($lesson)->andReturn([]);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonTest extends TestCase
|
||||
@@ -76,4 +77,21 @@ class LessonTest extends TestCase
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesSharesOneIntakeRegistration(): void
|
||||
{
|
||||
// Occurrence 12 of a series anchored on lesson 7: answered for once.
|
||||
$occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 7, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_LESSON, $occurrence->intakeRegistrationType());
|
||||
self::assertSame(7, $occurrence->intakeRegistrationId());
|
||||
// A single lesson is its own registration.
|
||||
self::assertSame(12, (new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->intakeRegistrationId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioBookedLessonIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Lesson(slotId: 10, studentId: 5, instructorId: 3, bookedBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,11 @@ class EnrollmentRepositoryTest extends TestCase
|
||||
return $d['offering_id'] === 7
|
||||
&& $d['student_id'] === 5
|
||||
&& $d['instructor_id'] === 3
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE;
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE
|
||||
// Enrolled through the student-facing flow: no staff enroller.
|
||||
&& $d['enrolled_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s']
|
||||
['%d', '%d', '%d', '%s', '%d', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 12;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentTest extends TestCase
|
||||
@@ -45,8 +46,25 @@ class EnrollmentTest extends TestCase
|
||||
{
|
||||
$arr = (new Enrollment(7, 5, 3, id: 12))->toArray();
|
||||
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) {
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id', 'enrolled_by'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnEnrolmentIsItsOwnIntakeRegistration(): void
|
||||
{
|
||||
$enrollment = new Enrollment(7, 5, 3, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_ENROLLMENT, $enrollment->intakeRegistrationType());
|
||||
// No series anchor to follow: a term of classes is one enrolment.
|
||||
self::assertSame(12, $enrollment->intakeRegistrationId());
|
||||
self::assertSame(7, $enrollment->intakeOfferingId());
|
||||
self::assertSame(5, $enrollment->intakeStudentId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioMadeEnrolmentIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Enrollment(7, 5, 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Enrollment(7, 5, 3, enrolledBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class GroupClassControllerTest extends TestCase
|
||||
@@ -27,6 +29,8 @@ class GroupClassControllerTest extends TestCase
|
||||
private PaymentService&Mockery\MockInterface $paymentService;
|
||||
private InviteRepository&Mockery\MockInterface $invites;
|
||||
private RegistrationMailer&Mockery\MockInterface $mailer;
|
||||
private IntakeAudit&Mockery\MockInterface $audit;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private GroupClassController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -40,6 +44,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService = Mockery::mock(PaymentService::class);
|
||||
$this->invites = Mockery::mock(InviteRepository::class);
|
||||
$this->mailer = Mockery::mock(RegistrationMailer::class);
|
||||
$this->audit = Mockery::mock(IntakeAudit::class);
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
$this->controller = new GroupClassController(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
@@ -48,6 +54,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService,
|
||||
$this->invites,
|
||||
$this->mailer,
|
||||
$this->audit,
|
||||
$this->intake,
|
||||
);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
@@ -411,7 +419,11 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
|
||||
// Stamped with the staff member who added them (user 3), which is what
|
||||
// later lets the studio record the intake it never had a chance to ask for.
|
||||
$this->enrollments->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Enrollment $e): bool => 3 === $e->enrolledBy && $e->isStaffRegistered()
|
||||
))->andReturn(44);
|
||||
|
||||
$payment = new Payment(
|
||||
studentId: 5,
|
||||
@@ -435,6 +447,103 @@ class GroupClassControllerTest extends TestCase
|
||||
self::assertStringContainsString('1 student(s) added to the class.', $html);
|
||||
}
|
||||
|
||||
public function testEnrollmentIdOpensTheIntakeDetailView(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 3: the studio added this student, so intake can be recorded.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($enrollment)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Enrolment details', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
}
|
||||
|
||||
public function testAnEnrolmentTheStudentMadeOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 0: the student enrolled themselves and gave their own answers.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->renderInstructor());
|
||||
}
|
||||
|
||||
public function testAnInstructorCannotOpenAnotherInstructorsEnrolment(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
|
||||
// Enrolment belongs to instructor 9; the current user is 3.
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)
|
||||
->andReturn(new Enrollment(offeringId: 8, studentId: 5, instructorId: 9, enrolledBy: 9, id: 44));
|
||||
$this->audit->shouldNotReceive('answers');
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('This enrolment could not be found.', $html);
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $html);
|
||||
}
|
||||
|
||||
public function testSubmittedEnrolmentIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'phone',
|
||||
'collected_note' => 'Called the parent',
|
||||
];
|
||||
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($enrollment, [9 => 'Nut allergy'], [6], 'phone', 'Called the parent', 3)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: Over the phone');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// The generic class-form handler must not also run and report a missing class.
|
||||
self::assertStringNotContainsString('That group class was not found.', $html);
|
||||
}
|
||||
|
||||
/** The lookups the enrolment detail view makes, with an empty audit trail. */
|
||||
private function expectEnrollmentDetail(Enrollment $enrollment): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)->andReturn($enrollment);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(8, 'Choir', 10));
|
||||
$this->audit->shouldReceive('answers')->with($enrollment)->andReturn([]);
|
||||
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
|
||||
}
|
||||
|
||||
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
|
||||
@@ -38,9 +38,13 @@ class AcceptanceRepositoryTest extends TestCase
|
||||
&& $d['registration_id'] === 12
|
||||
// No explicit acceptor: the student agreed for themselves.
|
||||
&& $d['accepted_by'] === 5
|
||||
&& $d['ip_address'] === '203.0.113.7';
|
||||
&& $d['ip_address'] === '203.0.113.7'
|
||||
// Ticked online: no collection provenance to record.
|
||||
&& null === $d['collected_via']
|
||||
&& null === $d['collected_note']
|
||||
&& 0 === $d['recorded_by'];
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s', '%s']
|
||||
['%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 1;
|
||||
|
||||
|
||||
@@ -36,9 +36,13 @@ class AnswerRepositoryTest extends TestCase
|
||||
&& $data['registration_type'] === Answer::REG_LESSON
|
||||
&& $data['registration_id'] === 12
|
||||
&& $data['student_id'] === 5
|
||||
&& $data['answer_value'] === 'Beginner';
|
||||
&& $data['answer_value'] === 'Beginner'
|
||||
// Given online: no collection provenance to record.
|
||||
&& null === $data['collected_via']
|
||||
&& null === $data['collected_note']
|
||||
&& 0 === $data['recorded_by'];
|
||||
}),
|
||||
['%d', '%s', '%d', '%d', '%s', '%s']
|
||||
['%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
$this->db->insert_id = 77;
|
||||
|
||||
@@ -10,7 +10,7 @@ class AnswerTest extends TestCase
|
||||
{
|
||||
public function testConstructorAndProperties(): void
|
||||
{
|
||||
$answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', 99);
|
||||
$answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', id: 99);
|
||||
|
||||
self::assertSame(3, $answer->questionId);
|
||||
self::assertSame(Answer::REG_LESSON, $answer->registrationType);
|
||||
|
||||
+46
-8
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
@@ -13,19 +13,21 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonDetailTest extends TestCase
|
||||
class IntakeAuditTest extends TestCase
|
||||
{
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private LessonDetail $detail;
|
||||
private IntakeAudit $detail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -37,7 +39,7 @@ class LessonDetailTest extends TestCase
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
|
||||
$this->detail = new LessonDetail(
|
||||
$this->detail = new IntakeAudit(
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->acceptances,
|
||||
@@ -58,8 +60,8 @@ class LessonDetailTest extends TestCase
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => '#9', 'answer' => '—'],
|
||||
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
|
||||
['question' => '#9', 'answer' => '—', 'source' => 'Given online when booking'],
|
||||
],
|
||||
$this->detail->answers($this->lesson(7))
|
||||
);
|
||||
@@ -88,12 +90,47 @@ class LessonDetailTest extends TestCase
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
'source' => 'Given online when booking',
|
||||
],
|
||||
],
|
||||
$this->detail->acceptances($this->lesson(7))
|
||||
);
|
||||
}
|
||||
|
||||
public function testACollectedElsewhereAnswerNamesItsSourceAndWhoRecordedIt(): void
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = 7;
|
||||
$user->first_name = 'Jane';
|
||||
$user->last_name = 'Doe';
|
||||
$user->nickname = 'jane';
|
||||
$user->display_name = 'jane';
|
||||
Functions\when('get_userdata')->justReturn($user);
|
||||
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||
new Answer(
|
||||
questionId: 2,
|
||||
registrationType: Answer::REG_LESSON,
|
||||
registrationId: 7,
|
||||
studentId: 5,
|
||||
answerValue: 'Nut allergy',
|
||||
collectedVia: IntakeProvenance::VIA_PAPER,
|
||||
collectedNote: 'Filed in the studio binder',
|
||||
recordedBy: 7
|
||||
),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Allergies', id: 2));
|
||||
|
||||
self::assertSame(
|
||||
[[
|
||||
'question' => 'Allergies',
|
||||
'answer' => 'Nut allergy',
|
||||
'source' => 'On a signed paper form — Filed in the studio binder — recorded by Jane Doe',
|
||||
]],
|
||||
$this->detail->answers($this->lesson(7))
|
||||
);
|
||||
}
|
||||
|
||||
public function testSeriesOccurrenceReadsTheAnchorsAnswersAndAcceptances(): void
|
||||
{
|
||||
// Occurrence #12 of a weekly reservation anchored on lesson 7: the intake
|
||||
@@ -119,7 +156,7 @@ class LessonDetailTest extends TestCase
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
self::assertSame(
|
||||
[['question' => 'Skill level', 'answer' => 'Beginner']],
|
||||
[['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking']],
|
||||
$this->detail->answers($occurrence)
|
||||
);
|
||||
self::assertSame(
|
||||
@@ -128,6 +165,7 @@ class LessonDetailTest extends TestCase
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
'source' => 'Given online when booking',
|
||||
]],
|
||||
$this->detail->acceptances($occurrence)
|
||||
);
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
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;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class IntakeRecordingTest extends TestCase
|
||||
{
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private RegistrationGate&Mockery\MockInterface $gate;
|
||||
private IntakeRecording $intake;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
|
||||
$this->intake = new IntakeRecording(
|
||||
$this->questions,
|
||||
$this->answers,
|
||||
$this->policies,
|
||||
$this->versions,
|
||||
$this->acceptances,
|
||||
$this->gate
|
||||
);
|
||||
}
|
||||
|
||||
public function testPendingListsOnlyWhatIsNotYetRecorded(): void
|
||||
{
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Skill level', isRequired: true, id: 2),
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
// Question 2 was already answered; only question 9 is still outstanding.
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'),
|
||||
]);
|
||||
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([
|
||||
new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1),
|
||||
]);
|
||||
$this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
$pending = $this->intake->pending($this->lesson());
|
||||
|
||||
self::assertSame([['id' => 9, 'label' => 'Anything we should know?', 'required' => false]], $pending['questions']);
|
||||
self::assertSame([['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']], $pending['policies']);
|
||||
}
|
||||
|
||||
public function testRecordsTheAnswersAndAcceptancesWithHowTheyWereCollected(): void
|
||||
{
|
||||
$this->expectPending();
|
||||
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(
|
||||
PolicyAcceptance::REG_LESSON,
|
||||
1,
|
||||
5,
|
||||
8,
|
||||
[9 => 'Nut allergy'],
|
||||
[6],
|
||||
// No IP: the student was never at a browser, and the staff member's
|
||||
// would be a false location in the audit trail.
|
||||
null,
|
||||
// No acceptor override either: the student agreed, on paper.
|
||||
0,
|
||||
Mockery::on(static fn (IntakeProvenance $p): bool => IntakeProvenance::VIA_PAPER === $p->collectedVia
|
||||
&& 'Filed in the studio binder' === $p->collectedNote
|
||||
&& 3 === $p->recordedBy)
|
||||
);
|
||||
|
||||
$notice = $this->intake->record(
|
||||
$this->lesson(),
|
||||
[9 => 'Nut allergy'],
|
||||
[6],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'Filed in the studio binder',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('1 answer', $notice);
|
||||
self::assertStringContainsString('1 policy acceptance', $notice);
|
||||
self::assertStringContainsString('On a signed paper form', $notice);
|
||||
self::assertStringContainsString('Filed in the studio binder', $notice);
|
||||
}
|
||||
|
||||
public function testALessonTheStudentBookedThemselvesCannotBeRecordedAgainst(): void
|
||||
{
|
||||
// No repository is even consulted: the guard comes first, so a student's
|
||||
// own answers can never be added to after the fact.
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record(
|
||||
$this->lesson(bookedBy: 0),
|
||||
[9 => 'Nut allergy'],
|
||||
[],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_recordable', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAlreadyRecordedAnswerOrAcceptanceIsIgnored(): void
|
||||
{
|
||||
$this->expectPending();
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
// Question 2 and version 4 are already on file — a stale form reposting
|
||||
// them must not duplicate or overwrite what is there.
|
||||
$result = $this->intake->record(
|
||||
$this->lesson(),
|
||||
[2 => 'Advanced'],
|
||||
[4],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('nothing_to_record', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testTheCollectionMethodIsRequiredAndMustBeOneOfTheKnownOnes(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], 'telepathy', '', 3);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_collection_method', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testOtherMustBeExplained(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], IntakeProvenance::VIA_OTHER, ' ', 3);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('collection_note_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesRecordsAgainstItsAnchor(): void
|
||||
{
|
||||
// Occurrence #12 of a series anchored on lesson 1: answered for once.
|
||||
$occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 1, bookedBy: 3, id: 12);
|
||||
|
||||
$this->expectPending();
|
||||
|
||||
// Registration id 1, not 12: the whole series shares one intake record, so
|
||||
// opening any occurrence shows and adds to the same answers.
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(PolicyAcceptance::REG_LESSON, 1, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any());
|
||||
|
||||
self::assertIsString($this->intake->record($occurrence, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_PHONE, '', 3));
|
||||
}
|
||||
|
||||
public function testAGroupClassEnrolmentRecordsAgainstTheEnrolmentTable(): void
|
||||
{
|
||||
// The same recorder, a different registration type: an enrolment is its own
|
||||
// registration, so nothing follows a series anchor here.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 7, id: 44);
|
||||
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_ENROLLMENT, 44)->andReturn([]);
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_ENROLLMENT, 44)->andReturn([]);
|
||||
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(Answer::REG_ENROLLMENT, 44, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any());
|
||||
|
||||
self::assertIsString($this->intake->record($enrollment, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_EMAIL, '', 7));
|
||||
}
|
||||
|
||||
public function testAnEnrolmentTheStudentMadeCannotBeRecordedAgainst(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record(
|
||||
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44),
|
||||
[9 => 'Nut allergy'],
|
||||
[],
|
||||
IntakeProvenance::VIA_EMAIL,
|
||||
'',
|
||||
7
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_recordable', $result->get_error_code());
|
||||
}
|
||||
|
||||
/** The repository responses behind a lesson with question 9 and version 6 outstanding. */
|
||||
private function expectPending(): void
|
||||
{
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Skill level', id: 2),
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'),
|
||||
]);
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([
|
||||
new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1),
|
||||
]);
|
||||
$this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
}
|
||||
|
||||
private function lesson(int $bookedBy = 3): Lesson
|
||||
{
|
||||
return new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: $bookedBy, id: 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user