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); } }