instructorId); self::assertSame(Offering::KIND_PRIVATE_LESSON, $offering->kind); self::assertSame('30-min Piano', $offering->title); self::assertSame(35.00, $offering->price); self::assertSame(30, $offering->durationMinutes); self::assertSame(42, $offering->id); } public function testNormalizeDateAcceptsRealDates(): void { self::assertSame('2026-09-08', Offering::normalizeDate('2026-09-08')); } public function testNormalizeDateRejectsGarbage(): void { self::assertNull(Offering::normalizeDate('')); self::assertNull(Offering::normalizeDate('not-a-date')); self::assertNull(Offering::normalizeDate('2026-02-30')); self::assertNull(Offering::normalizeDate('2026-09-08 10:00')); } public function testWeeklyTermEndAddsOneWeekPerExtraSession(): void { self::assertSame('2026-11-10', Offering::weeklyTermEnd('2026-09-08', 10)); } public function testWeeklyTermEndOfSingleSessionIsTheStartDate(): void { self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 1)); self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0)); } public function testNormalizeTimeAcceptsHtmlTimeInput(): void { self::assertSame('16:30:00', Offering::normalizeTime('16:30')); self::assertSame('09:00:00', Offering::normalizeTime('09:00:00')); } public function testNormalizeTimeRejectsGarbage(): void { self::assertNull(Offering::normalizeTime('')); self::assertNull(Offering::normalizeTime('25:00')); self::assertNull(Offering::normalizeTime('not-a-time')); } public function testSessionWindowsForOneOffClass(): void { $offering = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Recital', durationMinutes: 90, termStart: '2026-09-08', termEnd: '2026-09-08', classTime: '16:00:00', ); self::assertSame( [['start' => '2026-09-08 16:00:00', 'end' => '2026-09-08 17:30:00']], $offering->sessionWindows() ); } public function testSessionWindowsWalksWeeklyTerm(): void { $offering = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', durationMinutes: 60, termStart: '2026-09-08', termEnd: '2026-09-22', classTime: '16:00:00', ); $windows = $offering->sessionWindows(); self::assertCount(3, $windows); self::assertSame('2026-09-08 16:00:00', $windows[0]['start']); self::assertSame('2026-09-22 16:00:00', $windows[2]['start']); self::assertSame('2026-09-22 17:00:00', $windows[2]['end']); } public function testSessionWindowsEmptyWhenScheduleIncomplete(): void { // No class time. $noTime = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', durationMinutes: 60, termStart: '2026-09-08', termEnd: '2026-09-08', ); self::assertSame([], $noTime->sessionWindows()); // No duration. $noDuration = new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-09-08', termEnd: '2026-09-08', classTime: '16:00:00', ); self::assertSame([], $noDuration->sessionWindows()); } public function testDefaults(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); self::assertSame(0.0, $offering->price); self::assertSame('CAD', $offering->currency); self::assertSame(Offering::BILLING_ONE_TIME, $offering->billingMode); self::assertNull($offering->durationMinutes); self::assertFalse($offering->allowWeekly); self::assertNull($offering->capacity); self::assertTrue($offering->isActive); self::assertNull($offering->id); } public function testFromRowMapsCorrectlyAndCastsNullables(): void { $row = (object) [ 'id' => '7', 'instructor_id' => '3', 'kind' => Offering::KIND_GROUP_CLASS, 'title' => 'Year Choir', 'description' => 'Weekly choir', 'duration_minutes' => null, 'price' => '120.00', 'currency' => 'CAD', 'billing_mode' => Offering::BILLING_FULL_TERM, 'allow_weekly' => '0', 'capacity' => '20', 'term_start' => '2026-09-01', 'term_end' => '2027-06-30', 'schedule_note' => 'Tuesdays 4:00pm', 'etransfer_email' => null, 'cancellation_cutoff_hours' => '48', 'is_active' => '1', ]; $offering = Offering::fromRow($row); self::assertSame(7, $offering->id); self::assertSame(3, $offering->instructorId); self::assertNull($offering->durationMinutes); self::assertSame(120.00, $offering->price); self::assertSame(20, $offering->capacity); self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode); self::assertSame(48, $offering->cancellationCutoffHours); self::assertTrue($offering->isActive); } public function testFromRowMapsNullCancellationCutoff(): void { $row = (object) [ 'id' => '7', 'instructor_id' => '3', 'kind' => Offering::KIND_PRIVATE_LESSON, 'title' => '30 min lesson', 'description' => null, 'duration_minutes' => '30', 'price' => '40.00', 'currency' => 'CAD', 'billing_mode' => Offering::BILLING_ONE_TIME, 'allow_weekly' => '0', 'capacity' => null, 'term_start' => null, 'term_end' => null, 'schedule_note' => null, 'etransfer_email' => null, 'cancellation_cutoff_hours' => null, 'is_active' => '1', ]; $offering = Offering::fromRow($row); self::assertNull($offering->cancellationCutoffHours); } public function testToArrayContainsExpectedKeys(): void { $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10); $arr = $offering->toArray(); foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'access_mode', 'is_active'] as $key) { self::assertArrayHasKey($key, $arr); } } public function testDefaultsToPublicAccess(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', id: 10); self::assertSame(Offering::ACCESS_PUBLIC, $offering->accessMode); self::assertFalse($offering->isInviteOnly()); } public function testInviteOnlyAccessIsReported(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 10); self::assertTrue($offering->isInviteOnly()); self::assertSame(Offering::ACCESS_INVITE_ONLY, $offering->toArray()['access_mode']); } public function testFromRowReadsInviteOnlyAccessMode(): void { $row = (object) [ 'id' => '7', 'instructor_id' => '3', 'kind' => Offering::KIND_GROUP_CLASS, 'title' => 'Private Choir', 'description' => null, 'duration_minutes' => null, 'price' => '0.00', 'currency' => 'CAD', 'billing_mode' => Offering::BILLING_FULL_TERM, 'allow_weekly' => '0', 'capacity' => null, 'term_start' => null, 'term_end' => null, 'schedule_note' => null, 'etransfer_email' => null, 'cancellation_cutoff_hours' => null, 'access_mode' => Offering::ACCESS_INVITE_ONLY, 'is_active' => '1', ]; self::assertTrue(Offering::fromRow($row)->isInviteOnly()); } public function testToArrayIncludesEtransferEmailByDefault(): void { $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: 'studio@example.com', id: 10); self::assertArrayHasKey('etransfer_email', $offering->toArray()); self::assertSame('studio@example.com', $offering->toArray()['etransfer_email']); } public function testToArrayOmitsEtransferEmailWhenExcluded(): void { $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: 'studio@example.com', id: 10); self::assertArrayNotHasKey('etransfer_email', $offering->toArray(includeEtransferEmail: false)); } public function testValidKindAndBillingConstants(): void { self::assertContains(Offering::KIND_PRIVATE_LESSON, Offering::VALID_KINDS); self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS); self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES); self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES); self::assertContains(Offering::BILLING_WEEKLY, Offering::VALID_BILLING_MODES); self::assertContains(Offering::BILLING_MONTHLY, Offering::VALID_BILLING_MODES); } public function testIsScheduledBillingOnlyForWeeklyAndMonthly(): void { self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_ONE_TIME))->isScheduledBilling()); self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_FULL_TERM))->isScheduledBilling()); self::assertTrue((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_WEEKLY))->isScheduledBilling()); self::assertTrue((new Offering(1, Offering::KIND_GROUP_CLASS, 'A', billingMode: Offering::BILLING_MONTHLY))->isScheduledBilling()); } public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08'); self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline()); } public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31'); self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline()); } public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); self::assertNull($offering->effectiveEnrollmentDeadline()); } public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31'); self::assertTrue($offering->isEnrollmentOpen('2026-08-30')); self::assertTrue($offering->isEnrollmentOpen('2026-08-31')); self::assertFalse($offering->isEnrollmentOpen('2026-09-01')); } public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); self::assertTrue($offering->isEnrollmentOpen('2099-01-01')); } public function testToArrayIncludesEnrollmentDeadline(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10); self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']); } }