enrollments = Mockery::mock(EnrollmentRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); $this->schedule = new SessionSchedule($this->enrollments, $this->offerings); } /** A three-week Tuesday class at 16:00, one hour long. */ private function choir(int $id = 8, string $title = 'Choir'): Offering { return new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: $title, durationMinutes: 60, termStart: '2026-09-08', termEnd: '2026-09-22', classTime: '16:00:00', id: $id, ); } public function testAStudentsEnrolmentBecomesOneRowPerRemainingSession(): void { $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir()); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertCount(3, $rows); self::assertSame( ['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'], array_column($rows, 'start_dt') ); self::assertSame('2026-09-08 17:00:00', $rows[0]['end_dt']); self::assertSame('Choir', $rows[0]['offering_title']); self::assertSame(40, $rows[0]['enrollment_id']); self::assertSame(3, $rows[0]['instructor_id']); self::assertSame(60, $rows[0]['duration_minutes']); } public function testSessionsThatHaveAlreadyStartedAreLeftOut(): void { $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir()); // Mid-term: the first two dates are gone, the last is still to come. $rows = $this->schedule->upcomingForStudent(5, '2026-09-16 09:00:00'); self::assertSame(['2026-09-22 16:00:00'], array_column($rows, 'start_dt')); } public function testAWithdrawnEnrolmentContributesNothing(): void { $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment( offeringId: 8, studentId: 5, instructorId: 3, status: Enrollment::STATUS_CANCELLED, id: 40, ), ]); $this->offerings->shouldNotReceive('findById'); self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00')); } /** * "Completed" is a billing state, not a calendar one — the class may still * have dates left to run, so its sessions stay on the list. */ public function testACompletedEnrolmentStillListsItsRemainingSessions(): void { $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment( offeringId: 8, studentId: 5, instructorId: 3, status: Enrollment::STATUS_COMPLETED, id: 40, ), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir()); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertCount(3, $rows); self::assertSame(Enrollment::STATUS_COMPLETED, $rows[0]['status']); } /** * A class with no time set has no derivable sessions, so it is left out of a * dated list rather than shown at a time nobody chose. */ public function testAClassWithoutAScheduleYieldsNoRows(): void { $undated = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', durationMinutes: 60, termStart: '2026-09-08', id: 8, ); $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn($undated); self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00')); } public function testADeletedOfferingIsSkippedRatherThanFatal(): void { $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn(null); self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00')); } public function testTwoEnrolmentsAreInterleavedByDate(): void { $band = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Band', durationMinutes: 45, termStart: '2026-09-10', termEnd: '2026-09-10', classTime: '09:00:00', id: 9, ); $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 41), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir()); $this->offerings->shouldReceive('findById')->with(9)->andReturn($band); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertSame( ['Choir', 'Band', 'Choir', 'Choir'], array_column($rows, 'offering_title') ); } /** * An instructor's list is built from the classes they teach, not from who * has signed up: a class with no enrolments yet is still on their schedule. */ public function testAnInstructorSeesEachSessionOfTheirActiveClassesOnce(): void { $this->offerings->shouldReceive('findAll') ->once() ->with(3, Offering::KIND_GROUP_CLASS, true) ->andReturn([$this->choir()]); $this->enrollments->shouldNotReceive('findByStudent'); $rows = $this->schedule->upcomingForInstructor(3, '2026-09-01 00:00:00'); self::assertCount(3, $rows); self::assertSame(0, $rows[0]['enrollment_id']); self::assertSame(3, $rows[0]['instructor_id']); self::assertSame('Choir', $rows[0]['offering_title']); } }