Notify instructors on new bookings and enrolments
CI / Coding Standards (pull_request) Successful in 27s
CI / Tests (PHP 8.1) (pull_request) Successful in 37s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.3) (pull_request) Successful in 36s
CI / Tests (PHP 8.5) (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Static Analysis (pull_request) Successful in 48s
CI / Build Plugin Zip (pull_request) Skipped

Add an opt-in, per-instructor email notice sent when someone books one of
their lessons or enrols in one of their group classes. Off by default and
set from My Availability → Notifications; covers both the student/guardian
REST flows and the studio's wp-admin "book/add for a student" forms.

The opt-in check lives in InstructorNotificationMailer so no booking path
can drift on who is mailed; lessons fire from LessonBooker::settle (the one
step both booking paths reach), enrolments from EnrollmentEndpoint::enroll
and GroupClassController::addDirect. The notice is a courtesy and never
fails a booking or enrolment that otherwise succeeded.

Co-authored-by: anthropic/claude-opus-4-8
This commit is contained in:
2026-09-18 16:18:43 -03:00
co-authored by anthropic/claude-opus-4-8
parent 56fc0bd57d
commit 12765d8f13
16 changed files with 583 additions and 3 deletions
@@ -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);