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);
}
}