Notify instructors on new bookings and enrolments #205

Merged
thatguygriff merged 1 commits from feature/instructor-booking-notifications into main 2026-09-18 19:24:24 +00:00
16 changed files with 583 additions and 3 deletions
Showing only changes of commit 12765d8f13 - Show all commits
+3
View File
@@ -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
+84
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Emails an instructor when someone books one of their lessons or enrols in one
* of their group classes — but only when that instructor has opted in
* ({@see InstructorNotificationPref}).
*
* The opt-in check lives here, not at each call site, so the several places a
* booking or enrolment can be made — the student REST flows and the two wp-admin
* "add for a student" forms — cannot drift apart on who gets mailed: they hand
* over the instructor and what happened, and this decides whether to send. Every
* method returns false without sending when the instructor has not opted in,
* their account is gone, or it has no email on file, so a missing notification is
* never mistaken for a failed booking by the caller.
*/
class InstructorNotificationMailer {
public function __construct(
private InstructorNotificationPref $pref = new InstructorNotificationPref(),
) {}
/**
* Tell the instructor a lesson of theirs has just been booked. `$count` is the
* number of occurrences a weekly reservation claimed, so a single booking and
* a term booked at once read correctly.
*/
public function notifyLessonBooked( int $instructorId, string $studentName, string $offeringTitle, string $when, int $count = 1 ): bool {
$instructor = $this->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;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* An instructor's own choice of whether the studio emails them when someone
* enrols in one of their group classes or books one of their lessons.
*
* It is a per-instructor preference, kept in user meta rather than a studio-wide
* option, because the people it emails are the ones who should decide: one
* instructor wants a heads-up for every booking, another already lives in the
* roster and wants no extra mail. The instructor sets it from their own
* {@see \Unsupervised\Schedular\Availability\AvailabilityController My
* Availability} page.
*
* It defaults **off**. The notification is a new capability, and turning it on
* for every instructor on an existing site the day it ships would mail people
* who never asked to be mailed; an instructor who wants it opts in.
*/
class InstructorNotificationPref {
/**
* User-meta key holding the per-instructor toggle. Stored as `'1'` / `'0'`,
* mirroring the `us_*` string-boolean convention the plugin's options use.
*/
public const META_NOTIFY = 'us_notify_on_booking';
/**
* Whether the given instructor has asked to be emailed about new enrolments
* and bookings. Absent meta — the default for every account — reads as off.
*/
public function wants( int $instructorId ): bool {
if ( $instructorId <= 0 ) {
return false;
}
return '1' === Val::string( get_user_meta( $instructorId, self::META_NOTIFY, true ) );
}
/**
* Record an instructor's choice. Stored as `'0'` rather than deleted so a
* deliberate "no" is told apart from an account that never chose.
*/
public function set( int $instructorId, bool $wants ): void {
if ( $instructorId <= 0 ) {
return;
}
update_user_meta( $instructorId, self::META_NOTIFY, $wants ? '1' : '0' );
}
}
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -14,6 +15,7 @@ class AvailabilityController {
private AvailabilityRepository $repository,
private OfferingRepository $offerings,
private WindowValidator $validator,
private InstructorNotificationPref $notifyPref = new InstructorNotificationPref(),
) {}
public function renderPage(): void {
@@ -31,6 +33,7 @@ class AvailabilityController {
$slots = $this->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' ), '' ]
+24
View File
@@ -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
+12
View File
@@ -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() ) {
+13
View File
@@ -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;
}
+20
View File
@@ -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
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Notifications', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="save_notify">
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Booking emails', 'unsupervised-schedular'); ?></th>
<td>
<label>
<input type="checkbox" name="notify_on_booking" value="1" <?php checked($notifyOnBooking); ?>>
<?php esc_html_e('Email me when someone books a lesson or enrols in one of my group classes', 'unsupervised-schedular'); ?>
</label>
<p class="description"><?php esc_html_e('Off by default. Covers both your private lessons and your group classes, whether the student booked themselves or the studio booked for them.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Notification Preference', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
<form method="post" id="usc-add-availability">
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class InstructorNotificationMailerTest extends TestCase
{
private InstructorNotificationPref&Mockery\MockInterface $pref;
protected function setUp(): void
{
parent::setUp();
$this->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('[email protected]'));
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
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('[email protected]'));
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
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('[email protected]'));
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
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'));
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class InstructorNotificationPrefTest extends TestCase
{
public function testDefaultsOffWhenTheMetaIsUnset(): void
{
// Absent meta reads as an empty string, which is not the opt-in value.
Functions\when('get_user_meta')->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);
}
}
@@ -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();
+7 -1
View File
@@ -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)
);
}
+13 -1
View File
@@ -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,
@@ -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));
@@ -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);