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::assertNull($rows[0]['schedule']); 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']); } /** * Both the class time and the duration are optional on the offering form, and * the schedule note exists so a studio can say "Tuesdays 4:00pm" instead of * pinning the class to a clock. A class configured that way used to vanish * from the list entirely — the bug this covers. It now gets one row carrying * the note in place of a date. */ public function testAClassWithNoClassTimeStillGetsARowDescribedInWords(): 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(new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', durationMinutes: 60, termStart: '2026-09-08', termEnd: '2026-12-08', scheduleNote: 'Tuesdays 4:00pm', id: 8, )); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertCount(1, $rows); self::assertSame('Tuesdays 4:00pm', $rows[0]['schedule']); self::assertSame('Choir', $rows[0]['offering_title']); // A sort key, not a claim about the time: the class has not started yet, // so it sorts to its first day. self::assertSame('2026-09-08 00:00:00', $rows[0]['start_dt']); self::assertSame('', $rows[0]['end_dt']); } /** Without a note, the term dates do the describing. */ public function testAnUndatedClassFallsBackToItsTermDates(): void { Functions\when('mysql2date')->alias( static fn (string $format, string $date): string => date($format, (int) strtotime($date)) ); $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), ]); $this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-09-08', termEnd: '2026-12-08', id: 8, )); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertSame('Sep 8, 2026 – Dec 8, 2026', $rows[0]['schedule']); } /** * A term already under way sorts to "now" rather than to a start date in the * past, so an ongoing class reads as current instead of dropping to the * bottom of a list ordered by time. */ public function testAnUndatedClassAlreadyUnderWaySortsToNow(): 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(new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-09-08', termEnd: '2026-12-08', scheduleNote: 'Tuesdays 4:00pm', id: 8, )); $rows = $this->schedule->upcomingForStudent(5, '2026-10-01 09:00:00'); self::assertSame('2026-10-01 09:00:00', $rows[0]['start_dt']); } /** An undated class whose last day has passed is over, and drops out. */ public function testAnUndatedClassThatHasFinishedIsDropped(): 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(new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-09-08', termEnd: '2026-12-08', scheduleNote: 'Tuesdays 4:00pm', id: 8, )); self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-12-09 00:00:00')); } /** * A class time but no duration: the dates are still known, so they are still * listed — the row just says when it starts and not when it ends, rather than * inventing a finish. */ public function testAClassWithNoDurationKeepsItsDatesAndLeavesTheEndOpen(): 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(new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-09-08', termEnd: '2026-09-15', classTime: '16:00:00', id: 8, )); $rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'); self::assertSame(['2026-09-08 16:00:00', '2026-09-15 16:00:00'], array_column($rows, 'start_dt')); self::assertSame(['', ''], array_column($rows, 'end_dt')); self::assertNull($rows[0]['schedule']); } 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']); } }