diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ff315..d61f428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ each change under the current top section as you work. ## [1.6.1] +### Added +- **Instructors can now be emailed when someone books their lesson or enrols in their group class.** Until now an instructor had to open their schedule to discover a new booking; now each one can ask to be told the moment it happens. Turn it on from **My Availability → Notifications** — it is off by default, and it is each instructor's own choice, so one can opt in while another keeps their inbox quiet. Once on, it covers both private lessons and group classes, and it does not matter who did the booking: a student (or a parent for their child) booking themselves, or the studio booking on their behalf from wp-admin, all reach the instructor the same way. A term booked all at once says how many lessons it covers. The notice is a courtesy only — a booking or enrolment always goes through whether or not the email lands. + ## [1.6.0] ### Added diff --git a/docs/features/instructor-notifications.md b/docs/features/instructor-notifications.md new file mode 100644 index 0000000..50bc511 --- /dev/null +++ b/docs/features/instructor-notifications.md @@ -0,0 +1,84 @@ +# Feature: Instructor Booking Notifications + +## Overview +An instructor can ask to be emailed whenever someone books one of their private +lessons or enrols in one of their group classes. It is **off by default** and set +per instructor — the people it emails decide whether they want it. + +The notice fires whichever way the booking or enrolment was made: a student +(or a guardian for their child) doing it themselves through the front-end, or the +studio doing it on their behalf from wp-admin. There is no separate "the studio +booked it" case to forget — every path funnels through one place per registration +type, and the opt-in check lives in the mailer, not at each call site, so no path +can drift on who gets mailed. + +## Preference `us_notify_on_booking` (user meta) +`'1'` or `'0'`, stored against the instructor's WordPress user. Absent — the +default for every account — reads as off. Stored as `'0'` rather than deleted when +an instructor turns it off, so a deliberate "no" is told apart from never having +chosen. + +Default off because the notification is a new capability: turning it on for every +instructor on an existing site the day it ships would mail people who never asked. + +## Admin Interface +**My Availability → Notifications** (`manage_availability` — every instructor sees +their own availability page): + +- **Email me when someone books a lesson or enrols in one of my group classes** — + a single checkbox, off by default, saved on its own form (`usc_action=save_notify`). + +The page an instructor sets availability on is the one they already visit to shape +their teaching schedule, so the preference about that schedule lives beside it. + +## What triggers a notice +| Registration | Path | Where the notice fires | +|---|---|---| +| Private lesson | student/guardian REST **and** studio wp-admin form | `Booking\LessonBooker::settle()` — the single step both paths reach once a slot is claimed | +| Group class | student/guardian REST | `GroupClass\EnrollmentEndpoint::enroll()`, after the roster row is written | +| Group class | studio "Add students directly" (wp-admin) | `GroupClass\GroupClassController::addDirect()`, per student added | + +A weekly lesson reservation reports the number of occurrences claimed, so a single +booking and a term booked at once each read correctly. + +The notice is an opt-in courtesy, never a step a booking or enrolment depends on: +a missing or failed send can never fail a booking that otherwise succeeded, and +the mailer returns false (without sending) when the instructor has not opted in, +their account is gone, or it carries no email. + +## Implementation +- `Unsupervised\Schedular\Auth\InstructorNotificationPref` — the per-instructor + user-meta preference (`wants()` / `set()`), default off +- `Unsupervised\Schedular\Auth\InstructorNotificationMailer` — `notifyLessonBooked()` + and `notifyEnrollment()`; the opt-in check and recipient resolution live here +- `Unsupervised\Schedular\Booking\LessonBooker::settle()` — fires the lesson notice + for both booking paths +- `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint::enroll()` — student/guardian + enrolment notice +- `Unsupervised\Schedular\GroupClass\GroupClassController::addDirect()` — studio + enrolment notice +- `Unsupervised\Schedular\Availability\AvailabilityController` — reads the preference + for the page and saves the toggle (`save_notify`) +- `templates/admin/availability.php` — the Notifications checkbox + +All three consumers take the mailer (and the controller its preference) as a +constructor dependency defaulting to a fresh instance, so existing wiring in +`Plugin`, `RestRegistrar` and `AdminMenu` is unchanged. + +## Tests +- `tests/Unit/Auth/InstructorNotificationPrefTest.php` — default-off, opt-in read, + string-boolean write, non-user guards +- `tests/Unit/Auth/InstructorNotificationMailerTest.php` — sends to an opted-in + instructor, weekly occurrence count, and sends nothing when opted out / account + gone / no email +- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` — a successful enrolment + notifies the class instructor +- `tests/Unit/Availability/AvailabilityControllerTest.php` — the toggle saves an + opt-in and an opt-out +- The booking-path tests (`BookingEndpointTest`, `AdminBookingTest`) inject a mock + mailer, keeping them about booking + +## Related +- `lesson-booking.md` — the booking core the lesson notice hangs off +- `group-classes.md` — the enrolment paths the class notice hangs off +- `user-roles.md` — the instructor role and `manage_availability` capability diff --git a/src/Auth/InstructorNotificationMailer.php b/src/Auth/InstructorNotificationMailer.php new file mode 100644 index 0000000..5107599 --- /dev/null +++ b/src/Auth/InstructorNotificationMailer.php @@ -0,0 +1,104 @@ +recipient( $instructorId ); + if ( ! $instructor instanceof \WP_User ) { + return false; + } + + $subject = sprintf( + /* translators: %s: lesson type. */ + __( 'New booking: %s', 'unsupervised-schedular' ), + $offeringTitle + ); + + $body = $count > 1 + ? sprintf( + /* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */ + __( '%1$s has booked %2$s with you — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ), + $studentName, + $offeringTitle, + $count, + $when + ) + : sprintf( + /* translators: 1: student name, 2: lesson type, 3: lesson date and time. */ + __( '%1$s has booked %2$s with you on %3$s.', 'unsupervised-schedular' ), + $studentName, + $offeringTitle, + $when + ); + + return (bool) wp_mail( (string) $instructor->user_email, $subject, $body ); + } + + /** + * Tell the instructor a student has just enrolled in one of their group + * classes. + */ + public function notifyEnrollment( int $instructorId, string $studentName, string $classTitle ): bool { + $instructor = $this->recipient( $instructorId ); + if ( ! $instructor instanceof \WP_User ) { + return false; + } + + $subject = sprintf( + /* translators: %s: class title. */ + __( 'New enrolment: %s', 'unsupervised-schedular' ), + $classTitle + ); + + $body = sprintf( + /* translators: 1: student name, 2: class title. */ + __( '%1$s has enrolled in your group class "%2$s".', 'unsupervised-schedular' ), + $studentName, + $classTitle + ); + + return (bool) wp_mail( (string) $instructor->user_email, $subject, $body ); + } + + /** + * The instructor to mail, or null when there is nobody to mail: they have not + * opted in, their account has gone, or it carries no email address. + */ + private function recipient( int $instructorId ): ?\WP_User { + if ( ! $this->pref->wants( $instructorId ) ) { + return null; + } + + $user = get_userdata( $instructorId ); + if ( ! $user instanceof \WP_User || '' === (string) $user->user_email ) { + return null; + } + + return $user; + } +} diff --git a/src/Auth/InstructorNotificationPref.php b/src/Auth/InstructorNotificationPref.php new file mode 100644 index 0000000..bf74885 --- /dev/null +++ b/src/Auth/InstructorNotificationPref.php @@ -0,0 +1,54 @@ +repository->findByInstructor( $instructorId ); $offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true ); + $notifyOnBooking = $this->notifyPref->wants( $instructorId ); // View-state query params only (which view, which week) — nothing is // mutated from them, so no nonce applies. @@ -64,6 +67,12 @@ class AvailabilityController { return $this->addSlot( $instructorId ); } + if ( 'save_notify' === $action ) { + $this->notifyPref->set( $instructorId, isset( $_POST['notify_on_booking'] ) ); + + return [ __( 'Notification preference saved.', 'unsupervised-schedular' ), '' ]; + } + if ( 'delete' === $action ) { return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId ) ? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ] diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index 649119e..5f35bb9 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Booking; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; +use Unsupervised\Schedular\Auth\UserName; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Guardian\GuardianService; @@ -41,6 +43,7 @@ class LessonBooker { private OfferingRepository $offerings, private PaymentService $payments, private GuardianService $guardians, + private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(), ) {} /** @@ -167,6 +170,13 @@ class LessonBooker { * @return array{status: string, payment: ?Payment} */ public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array { + // Both booking paths — the student's own and the studio's on their behalf — + // funnel through here once the slot is claimed, so telling the instructor + // once here notifies them however the lesson came to be booked. It is an + // opt-in courtesy, never a step the booking depends on, so it never fails a + // booking that otherwise succeeded. + $this->notifyInstructor( $slot, $offering, $studentId, count( $ids ) ); + // Scheduled billing (weekly / monthly) normally defers payment to the daily // scan, but a single lesson booked once its scheduled due date has already // passed — e.g. an extra lesson added to a month that was already billed — is @@ -223,6 +233,20 @@ class LessonBooker { ]; } + /** + * Send the instructor the heads-up their preference asks for, resolving the + * student's public name and the lesson's date the same way the studio's own + * booking notice does. The mailer itself decides whether the instructor wants + * it; here we only build what it needs to say. + */ + private function notifyInstructor( AvailabilitySlot $slot, Offering $offering, int $studentId, int $count ): void { + $student = get_userdata( $studentId ); + $studentName = UserName::format( $student instanceof \WP_User ? $student : null, $studentId ); + $when = Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ); + + $this->instructorMailer->notifyLessonBooked( $slot->instructorId, $studentName, $offering->title, $when, $count ); + } + /** * Whether a scheduled-billing offering's due date for a lesson has already * gone by — monthly bills on the first of the lesson's month, weekly the day diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 8e4d5af..3250446 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -3,7 +3,9 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Auth\UserName; use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; @@ -22,6 +24,7 @@ class EnrollmentEndpoint { private PaymentService $payments, private GroupAccessRepository $access, private GuardianService $guardians, + private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(), ) {} /** @@ -156,6 +159,15 @@ class EnrollmentEndpoint { // boxes — the guardian, when they enrolled a child. $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() ); + // Heads-up to the instructor if they asked for one. An opt-in courtesy, not + // a step the enrolment depends on, so it never fails an enrolment that took. + $student = get_userdata( $studentId ); + $this->instructorMailer->notifyEnrollment( + $offering->instructorId, + UserName::format( $student instanceof \WP_User ? $student : null, $studentId ), + $offering->title + ); + // Mark the access grant used so instructor rosters distinguish invited // students from enrolled ones (a no-op for public classes). if ( $offering->isInviteOnly() ) { diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 783c40d..c6a05af 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\Auth\Invite; use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RegistrationController; @@ -31,6 +32,7 @@ class GroupClassController { private RegistrationMailer $mailer, private IntakeAudit $audit, private IntakeRecording $intake, + private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(), ) {} /** @@ -505,6 +507,17 @@ class GroupClassController { } $this->access->markEnrolled( (int) $offering->id, $studentId ); + + // Heads-up to the instructor if they opted in — the same courtesy a + // student's own enrolment sends, so an enrolment the studio makes on + // their behalf reaches them the same way. + $student = get_userdata( $studentId ); + $this->instructorMailer->notifyEnrollment( + $offering->instructorId, + UserName::format( $student instanceof \WP_User ? $student : null, $studentId ), + $offering->title + ); + ++$added; } diff --git a/templates/admin/availability.php b/templates/admin/availability.php index c6fa63d..e68d904 100644 --- a/templates/admin/availability.php +++ b/templates/admin/availability.php @@ -15,6 +15,7 @@ if (! defined('ABSPATH')) { * @var string $nextWeek * @var string $notice Success message from the submitted action; empty when none. * @var string $error Failure message from the submitted action; empty when none. + * @var bool $notifyOnBooking Whether the instructor is emailed on new bookings/enrolments. */ use Unsupervised\Schedular\Availability\AvailabilitySlot; @@ -44,6 +45,25 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability

+

+
+ + + + + + + +
+ +

+
+ +
+

diff --git a/tests/Unit/Auth/InstructorNotificationMailerTest.php b/tests/Unit/Auth/InstructorNotificationMailerTest.php new file mode 100644 index 0000000..c489d1f --- /dev/null +++ b/tests/Unit/Auth/InstructorNotificationMailerTest.php @@ -0,0 +1,114 @@ +pref = Mockery::mock(InstructorNotificationPref::class); + } + + private function mailer(): InstructorNotificationMailer + { + return new InstructorNotificationMailer($this->pref); + } + + private function instructor(string $email): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->user_email = $email; + + return $user; + } + + public function testLessonNoticeGoesToAnOptedInInstructor(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(true); + Functions\when('get_userdata')->justReturn($this->instructor('teacher@studio.test')); + + Functions\expect('wp_mail') + ->once() + ->with( + 'teacher@studio.test', + Mockery::on(static fn (string $subject): bool => str_contains($subject, '30 min piano')), + Mockery::on(static fn (string $body): bool => str_contains($body, 'Ada') && str_contains($body, 'Jul 1')) + ) + ->andReturn(true); + + self::assertTrue($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM')); + } + + public function testWeeklyLessonNoticeCountsTheOccurrences(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(true); + Functions\when('get_userdata')->justReturn($this->instructor('teacher@studio.test')); + + Functions\expect('wp_mail') + ->once() + ->with( + 'teacher@studio.test', + Mockery::type('string'), + Mockery::on(static fn (string $body): bool => str_contains($body, '3 weekly lessons')) + ) + ->andReturn(true); + + self::assertTrue($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM', 3)); + } + + public function testEnrollmentNoticeGoesToAnOptedInInstructor(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(true); + Functions\when('get_userdata')->justReturn($this->instructor('teacher@studio.test')); + + Functions\expect('wp_mail') + ->once() + ->with( + 'teacher@studio.test', + Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')), + Mockery::on(static fn (string $body): bool => str_contains($body, 'Ada') && str_contains($body, 'Choir')) + ) + ->andReturn(true); + + self::assertTrue($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir')); + } + + public function testSendsNothingWhenTheInstructorHasNotOptedIn(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(false); + // Not even a user lookup: the preference is the first gate. + Functions\expect('get_userdata')->never(); + Functions\expect('wp_mail')->never(); + + self::assertFalse($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM')); + self::assertFalse($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir')); + } + + public function testSendsNothingWhenTheInstructorAccountIsGone(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(true); + Functions\when('get_userdata')->justReturn(false); + Functions\expect('wp_mail')->never(); + + self::assertFalse($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir')); + } + + public function testSendsNothingWhenTheInstructorHasNoEmail(): void + { + $this->pref->shouldReceive('wants')->with(9)->andReturn(true); + Functions\when('get_userdata')->justReturn($this->instructor('')); + Functions\expect('wp_mail')->never(); + + self::assertFalse($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM')); + } +} diff --git a/tests/Unit/Auth/InstructorNotificationPrefTest.php b/tests/Unit/Auth/InstructorNotificationPrefTest.php new file mode 100644 index 0000000..8484691 --- /dev/null +++ b/tests/Unit/Auth/InstructorNotificationPrefTest.php @@ -0,0 +1,64 @@ +justReturn(''); + + self::assertFalse((new InstructorNotificationPref())->wants(7)); + } + + public function testReadsAStoredOptIn(): void + { + Functions\when('get_user_meta')->alias( + static fn (int $id, string $key): string => 7 === $id && InstructorNotificationPref::META_NOTIFY === $key ? '1' : '' + ); + + self::assertTrue((new InstructorNotificationPref())->wants(7)); + } + + public function testAStoredNoReadsAsOff(): void + { + Functions\when('get_user_meta')->justReturn('0'); + + self::assertFalse((new InstructorNotificationPref())->wants(7)); + } + + public function testNobodyWantsNothing(): void + { + // A zero id is not a user; it must never read as opted-in. + Functions\expect('get_user_meta')->never(); + + self::assertFalse((new InstructorNotificationPref())->wants(0)); + } + + public function testSetStoresTheChoiceAsAStringBoolean(): void + { + Functions\expect('update_user_meta')->once()->with(7, InstructorNotificationPref::META_NOTIFY, '1')->andReturn(true); + + (new InstructorNotificationPref())->set(7, true); + } + + public function testSetStoresADeliberateNoRatherThanDeleting(): void + { + Functions\expect('update_user_meta')->once()->with(7, InstructorNotificationPref::META_NOTIFY, '0')->andReturn(true); + + (new InstructorNotificationPref())->set(7, false); + } + + public function testSetIgnoresANonUser(): void + { + Functions\expect('update_user_meta')->never(); + + (new InstructorNotificationPref())->set(0, true); + } +} diff --git a/tests/Unit/Availability/AvailabilityControllerTest.php b/tests/Unit/Availability/AvailabilityControllerTest.php index 971263f..289512c 100644 --- a/tests/Unit/Availability/AvailabilityControllerTest.php +++ b/tests/Unit/Availability/AvailabilityControllerTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Availability; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InstructorNotificationPref; use Unsupervised\Schedular\Availability\AvailabilityController; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; @@ -16,6 +17,7 @@ class AvailabilityControllerTest extends TestCase { private AvailabilityRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $offerings; + private InstructorNotificationPref&Mockery\MockInterface $notifyPref; private AvailabilityController $controller; protected function setUp(): void @@ -24,7 +26,12 @@ class AvailabilityControllerTest extends TestCase $this->repository = Mockery::mock(AvailabilityRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); - $this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings)); + // The notification preference is exercised in its own tests; here it simply + // reads off and accepts any save, so availability tests stay about slots. + $this->notifyPref = Mockery::mock(InstructorNotificationPref::class); + $this->notifyPref->shouldReceive('wants')->andReturn(false)->byDefault(); + $this->notifyPref->shouldReceive('set')->byDefault(); + $this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings), $this->notifyPref); $_POST = []; $_GET = []; @@ -286,6 +293,29 @@ class AvailabilityControllerTest extends TestCase self::assertStringContainsString('1 slot could not be deleted', $html); } + public function testTickingTheNotificationBoxSavesAnOptIn(): void + { + $_POST = ['usc_action' => 'save_notify', 'notify_on_booking' => '1']; + + $this->notifyPref->shouldReceive('set')->once()->with(3, true); + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $html = $this->render(); + + self::assertStringContainsString('notice-success', $html); + self::assertStringContainsString('Notification preference saved.', $html); + } + + public function testLeavingTheNotificationBoxUntickedSavesAnOptOut(): void + { + $_POST = ['usc_action' => 'save_notify']; + + $this->notifyPref->shouldReceive('set')->once()->with(3, false); + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $this->render(); + } + private function render(): string { ob_start(); diff --git a/tests/Unit/Booking/AdminBookingTest.php b/tests/Unit/Booking/AdminBookingTest.php index 2bfb307..e5a354f 100644 --- a/tests/Unit/Booking/AdminBookingTest.php +++ b/tests/Unit/Booking/AdminBookingTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; @@ -59,10 +60,15 @@ class AdminBookingTest extends TestCase // The real booker over mocked repositories: an admin booking must go // through exactly the machinery a student's own booking does. + // The opt-in instructor notice is tested on its own; a mock keeps these + // tests about booking, not about who gets emailed. + $instructorMailer = Mockery::mock(InstructorNotificationMailer::class); + $instructorMailer->shouldReceive('notifyLessonBooked')->andReturn(true)->byDefault(); + $this->admin = new AdminBooking( $this->availability, $this->offerings, - new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians) + new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians, $instructorMailer) ); } diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index 9001257..aebb82f 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Booking\BookingEndpoint; @@ -46,6 +47,12 @@ class BookingEndpointTest extends TestCase // Fixed "now" well before the fixture slot start (2026-07-01 10:00), so // the cancellation cutoff never trips unless a test moves it. Functions\when('current_time')->justReturn('2026-06-01 10:00:00'); + // A successful booking resolves the student's name and the lesson date for + // the (mocked) instructor notice; neither shapes what these tests assert. + Functions\when('get_userdata')->justReturn(false); + Functions\when('mysql2date')->alias( + static fn (string $format, string $date): string => date($format, (int) strtotime($date)) + ); $this->availability = Mockery::mock(AvailabilityRepository::class); $this->bookings = Mockery::mock(BookingRepository::class); @@ -78,6 +85,11 @@ class BookingEndpointTest extends TestCase $this->sessions->shouldReceive('upcomingForStudent')->andReturn([])->byDefault(); $this->sessions->shouldReceive('upcomingForInstructor')->andReturn([])->byDefault(); + // The opt-in instructor notice is tested on its own; a mock keeps these + // tests about booking, not about who gets emailed. + $instructorMailer = Mockery::mock(InstructorNotificationMailer::class); + $instructorMailer->shouldReceive('notifyLessonBooked')->andReturn(true)->byDefault(); + $this->endpoint = new BookingEndpoint( $this->availability, $this->bookings, @@ -87,7 +99,7 @@ class BookingEndpointTest extends TestCase // 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 LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians, $instructorMailer), new CancellationPolicy($this->settings), $this->guardians, $this->sessions, diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php index 7502fb7..3c5ebbc 100644 --- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; @@ -26,6 +27,7 @@ class EnrollmentEndpointTest extends TestCase private RegistrationGate $gate; private PaymentService $payments; private GroupAccessRepository $access; + private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer; private EnrollmentEndpoint $endpoint; protected function setUp(): void @@ -37,6 +39,9 @@ class EnrollmentEndpointTest extends TestCase Functions\when('sanitize_text_field')->returnArg(); Functions\when('get_current_user_id')->justReturn(5); Functions\when('current_time')->justReturn('2026-07-24'); + // The enrolment notice resolves the student's name before handing off to the + // (mocked) mailer; a bare false is enough since the name falls back to the id. + Functions\when('get_userdata')->justReturn(false); $this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); @@ -44,6 +49,11 @@ class EnrollmentEndpointTest extends TestCase $this->payments = Mockery::mock(PaymentService::class); $this->access = Mockery::mock(GroupAccessRepository::class); + // The opt-in instructor notice is exercised on its own; here it is a mock + // that ignores whatever it is handed, so enrolment tests stay about enrolment. + $this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class); + $this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault(); + $this->guardians = Mockery::mock(GuardianService::class); $this->guardians->shouldReceive('canActFor') ->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault(); @@ -57,6 +67,7 @@ class EnrollmentEndpointTest extends TestCase $this->payments, $this->access, $this->guardians, + $this->instructorMailer, ); } @@ -93,6 +104,19 @@ class EnrollmentEndpointTest extends TestCase self::assertNull($result->get_data()['payment']); } + public function testASuccessfulEnrolmentNotifiesTheClassInstructor(): void + { + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0)); + $this->expectSuccessfulEnrollment(); + $this->payments->shouldNotReceive('createForRegistration'); + + // The class is taught by instructor 3; the enrolling student falls back to + // their id for a name (get_userdata is stubbed false in setUp). + $this->instructorMailer->shouldReceive('notifyEnrollment')->once()->with(3, '5', 'Choir')->andReturn(true); + + $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + } + public function testEnrollInPricedClassReturnsPaymentSummary(): void { $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0)); diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php index fb8874f..852e4b0 100644 --- a/tests/Unit/GroupClass/GroupClassControllerTest.php +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InstructorNotificationMailer; use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RoleManager; @@ -32,6 +33,7 @@ class GroupClassControllerTest extends TestCase private RegistrationMailer&Mockery\MockInterface $mailer; private IntakeAudit&Mockery\MockInterface $audit; private IntakeRecording&Mockery\MockInterface $intake; + private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer; private GroupClassController $controller; protected function setUp(): void @@ -47,6 +49,10 @@ class GroupClassControllerTest extends TestCase $this->mailer = Mockery::mock(RegistrationMailer::class); $this->audit = Mockery::mock(IntakeAudit::class); $this->intake = Mockery::mock(IntakeRecording::class); + // The opt-in instructor notice is tested on its own; a mock keeps these + // tests about enrolment, not about who gets emailed. + $this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class); + $this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault(); $this->controller = new GroupClassController( $this->enrollments, $this->offerings, @@ -57,6 +63,7 @@ class GroupClassControllerTest extends TestCase $this->mailer, $this->audit, $this->intake, + $this->instructorMailer, ); Functions\when('current_user_can')->justReturn(true);