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
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:
co-authored by
anthropic/claude-opus-4-8
parent
56fc0bd57d
commit
12765d8f13
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user