Require an offering on every lesson booking, with a student-facing picker #56
+103
-9
@@ -210,23 +210,84 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active private-lesson offerings per instructor, so revisiting the
|
||||||
|
// registration form does not refetch the same catalog.
|
||||||
|
const offeringCache = new Map();
|
||||||
|
|
||||||
|
function instructorOfferings(instructorId) {
|
||||||
|
if (offeringCache.has(instructorId)) {
|
||||||
|
return Promise.resolve(offeringCache.get(instructorId));
|
||||||
|
}
|
||||||
|
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
|
||||||
|
offeringCache.set(instructorId, list);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
||||||
|
function offeringLabel(o) {
|
||||||
|
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||||
|
const price = Number(o.price) > 0
|
||||||
|
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
||||||
|
: 'Free';
|
||||||
|
return `${o.title} (${duration}${price})`;
|
||||||
|
}
|
||||||
|
|
||||||
function openRegistration(slot) {
|
function openRegistration(slot) {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
const offeringId = Number(slot.offering_id) || 0;
|
|
||||||
const qPath = offeringId ? `offerings/${offeringId}/questions` : null;
|
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
qPath ? apiFetch(qPath) : Promise.resolve([]),
|
instructorOfferings(Number(slot.instructor_id)),
|
||||||
apiFetch('policies?scope=booking'),
|
apiFetch('policies?scope=booking'),
|
||||||
])
|
])
|
||||||
.then(([questions, policies]) => {
|
.then(([offerings, policies]) => {
|
||||||
renderRegistration(slot, offeringId, questions, policies);
|
renderRegistration(slot, offerings, policies);
|
||||||
})
|
})
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRegistration(slot, offeringId, questions, policies) {
|
function offeringFieldHtml(tied, tiedId, choices) {
|
||||||
|
if (tiedId) {
|
||||||
|
// The slot is tied to one offering: show it locked so the student
|
||||||
|
// sees exactly what they are booking.
|
||||||
|
const label = tied ? offeringLabel(tied) : `Offering #${tiedId}`;
|
||||||
|
return `
|
||||||
|
<p class="us-offering">
|
||||||
|
<label>Lesson type<br>
|
||||||
|
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<p class="us-offering">
|
||||||
|
<label>Lesson type<br>
|
||||||
|
<select id="us-offering" required>
|
||||||
|
<option value="">— Choose a lesson type —</option>
|
||||||
|
${choices.map((o) => `<option value="${o.id}">${escHtml(offeringLabel(o))}</option>`).join('')}
|
||||||
|
</select></label>
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRegistration(slot, offerings, policies) {
|
||||||
|
const tiedId = Number(slot.offering_id) || 0;
|
||||||
|
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
|
||||||
|
|
||||||
|
// Generic slots offer every lesson type that fits the slot's length.
|
||||||
|
const choices = tiedId
|
||||||
|
? []
|
||||||
|
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
|
||||||
|
|
||||||
|
if (!tiedId && !choices.length) {
|
||||||
|
// The server rejects offering-less bookings, so without a matching
|
||||||
|
// lesson type this time cannot be booked online.
|
||||||
|
slotList.innerHTML = `
|
||||||
|
<div class="us-register">
|
||||||
|
<p>This time cannot be booked online right now. Please contact the instructor.</p>
|
||||||
|
<p><button type="button" id="us-cancel" class="us-cancel-btn">Back</button></p>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const weekly = slot.recurrence_group
|
const weekly = slot.recurrence_group
|
||||||
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
|
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
|
||||||
: '';
|
: '';
|
||||||
@@ -235,7 +296,8 @@
|
|||||||
<div class="us-register">
|
<div class="us-register">
|
||||||
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}</h3>
|
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}</h3>
|
||||||
<form id="us-register-form">
|
<form id="us-register-form">
|
||||||
${questions.map(questionField).join('')}
|
${offeringFieldHtml(tied, tiedId, choices)}
|
||||||
|
<div id="us-questions"></div>
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
${weekly}
|
${weekly}
|
||||||
<p>
|
<p>
|
||||||
@@ -245,10 +307,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// The intake questions belong to the selected offering, so they follow
|
||||||
|
// the picker instead of being fixed at render time.
|
||||||
|
let selectedId = tiedId;
|
||||||
|
let questions = [];
|
||||||
|
|
||||||
|
const questionsBox = document.getElementById('us-questions');
|
||||||
|
|
||||||
|
function loadQuestions() {
|
||||||
|
questions = [];
|
||||||
|
questionsBox.innerHTML = '';
|
||||||
|
if (!selectedId) return;
|
||||||
|
apiFetch(`offerings/${selectedId}/questions`)
|
||||||
|
.then((qs) => {
|
||||||
|
questions = qs;
|
||||||
|
questionsBox.innerHTML = qs.map(questionField).join('');
|
||||||
|
})
|
||||||
|
.catch((err) => showError(err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tiedId) {
|
||||||
|
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||||
|
selectedId = Number(e.target.value) || 0;
|
||||||
|
loadQuestions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQuestions();
|
||||||
|
|
||||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitBooking(e.target, slot, offeringId, questions);
|
if (!selectedId) {
|
||||||
|
showError('Please choose a lesson type.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBooking(e.target, slot, selectedId, questions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
|
|
||||||
## Registration Flow
|
## Registration Flow
|
||||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as an agenda list or a weekly calendar (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as an agenda list or a weekly calendar (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
||||||
2. Student picks an **offering** (a 30 or 60-minute private-lesson type) and a slot.
|
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (no offering, or a free offering) creates no payment and is `confirmed` immediately.
|
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
|
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
|
||||||
@@ -61,6 +61,11 @@ offering).
|
|||||||
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
|
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
|
||||||
nothing is owed (the front end then skips the payment step).
|
nothing is owed (the front end then skips the payment step).
|
||||||
|
|
||||||
|
An offering is always required (`400 offering_required` otherwise): a slot tied
|
||||||
|
to an offering uses that offering regardless of the request, while a generic
|
||||||
|
slot uses the student's `offering_id`, which must be one of the instructor's
|
||||||
|
active `private_lesson` offerings whose `duration_minutes` matches the slot.
|
||||||
|
|
||||||
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
|
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
|
||||||
for students; the instructor's for callers with `manage_availability`), each
|
for students; the instructor's for callers with `manage_availability`), each
|
||||||
with the slot's `start_dt`/`end_dt`.
|
with the slot's `start_dt`/`end_dt`.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
use Unsupervised\Schedular\Payment\PaymentService;
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
@@ -165,15 +166,35 @@ class BookingEndpoint {
|
|||||||
$offeringId = $requestedOfferingId;
|
$offeringId = $requestedOfferingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
|
// Every lesson books against an offering: it carries the price, intake
|
||||||
if ( $offeringId > 0 && null === $offering ) {
|
// questions, and payment routing. Without one the booking would silently
|
||||||
|
// be free and unquestioned, so generic slots require the student's choice.
|
||||||
|
if ( $offeringId <= 0 ) {
|
||||||
|
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$offering = $this->offerings->findById( $offeringId );
|
||||||
|
if ( null === $offering ) {
|
||||||
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( null !== $offering && $offering->instructorId !== $slot->instructorId ) {
|
if ( $offering->instructorId !== $slot->instructorId ) {
|
||||||
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A slot-tied offering was the instructor's explicit choice and is honoured
|
||||||
|
// as-is; a student-chosen one must be something the catalog actually offers
|
||||||
|
// for this slot: an active private-lesson type whose length fits the slot.
|
||||||
|
if ( 0 === $slotOfferingId ) {
|
||||||
|
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
|
||||||
|
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
|
||||||
|
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$answers = $this->answers( $request );
|
$answers = $this->answers( $request );
|
||||||
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
|
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
|
||||||
|
|
||||||
@@ -192,7 +213,7 @@ class BookingEndpoint {
|
|||||||
slotId: $slotId,
|
slotId: $slotId,
|
||||||
studentId: $studentId,
|
studentId: $studentId,
|
||||||
instructorId: $slot->instructorId,
|
instructorId: $slot->instructorId,
|
||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
offeringId: $offeringId,
|
||||||
recurrence: $recurrence,
|
recurrence: $recurrence,
|
||||||
notes: '' !== $notes ? $notes : null,
|
notes: '' !== $notes ? $notes : null,
|
||||||
);
|
);
|
||||||
@@ -227,14 +248,14 @@ class BookingEndpoint {
|
|||||||
$payment = null;
|
$payment = null;
|
||||||
$status = Lesson::STATUS_PENDING;
|
$status = Lesson::STATUS_PENDING;
|
||||||
|
|
||||||
if ( null !== $offering && $offering->price > 0.0 ) {
|
if ( $offering->price > 0.0 ) {
|
||||||
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||||
|
|
||||||
if ( null !== $payment && $payment->isPaid() ) {
|
if ( null !== $payment && $payment->isPaid() ) {
|
||||||
$status = Lesson::STATUS_CONFIRMED;
|
$status = Lesson::STATUS_CONFIRMED;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Nothing owed: there is no payment step that would confirm these
|
// Free offering: there is no payment step that would confirm these
|
||||||
// lessons later, so they are confirmed at booking time.
|
// lessons later, so they are confirmed at booking time.
|
||||||
foreach ( $ids as $lessonId ) {
|
foreach ( $ids as $lessonId ) {
|
||||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||||
|
|||||||
@@ -98,13 +98,16 @@ class BookingEndpointTest extends TestCase
|
|||||||
|
|
||||||
public function testBookReturns409WhenSlotClaimFails(): void
|
public function testBookReturns409WhenSlotClaimFails(): void
|
||||||
{
|
{
|
||||||
// Generic slot, no offering; another request wins the claim first.
|
// Generic slot; another request wins the claim first.
|
||||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8)
|
||||||
|
);
|
||||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false);
|
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false);
|
||||||
$this->bookings->shouldNotReceive('insert');
|
$this->bookings->shouldNotReceive('insert');
|
||||||
|
|
||||||
$request = new \WP_REST_Request(['slot_id' => 10]);
|
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||||
$result = $this->endpoint->book($request);
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
self::assertInstanceOf(\WP_Error::class, $result);
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
@@ -135,22 +138,102 @@ class BookingEndpointTest extends TestCase
|
|||||||
self::assertNull($result->get_data()['payment']);
|
self::assertNull($result->get_data()['payment']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBookWithoutOfferingConfirmsImmediatelyWithNoPayment(): void
|
public function testBookWithoutOfferingIsRejected(): void
|
||||||
{
|
{
|
||||||
|
// A booking with no offering would be silently free, so it must be refused.
|
||||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||||
|
$this->offerings->shouldNotReceive('findById');
|
||||||
|
$this->availability->shouldNotReceive('claim');
|
||||||
|
$this->bookings->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request(['slot_id' => 10]);
|
||||||
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('offering_required', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookUsesSlotTiedOfferingWhenRequestOmitsIt(): void
|
||||||
|
{
|
||||||
|
// Slot tied to offering 5: the booking must charge that offering even
|
||||||
|
// though the client sent no offering_id.
|
||||||
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 5));
|
||||||
|
$this->offerings->shouldReceive('findById')->with(5)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 5)
|
||||||
|
);
|
||||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
$this->bookings->shouldReceive('insert')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::on(static fn (Lesson $l): bool => 5 === $l->offeringId))
|
||||||
|
->andReturn(77);
|
||||||
$this->gate->shouldReceive('record')->once();
|
$this->gate->shouldReceive('record')->once();
|
||||||
$this->payments->shouldNotReceive('createForRegistration');
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
->once()
|
||||||
|
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
|
||||||
|
->andReturn(new Payment(
|
||||||
|
studentId: 5,
|
||||||
|
instructorId: 3,
|
||||||
|
registrationType: Payment::REG_LESSON,
|
||||||
|
registrationId: 77,
|
||||||
|
amount: 50.0,
|
||||||
|
method: Payment::METHOD_ETRANSFER,
|
||||||
|
status: Payment::STATUS_PENDING,
|
||||||
|
id: 12,
|
||||||
|
));
|
||||||
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
$request = new \WP_REST_Request(['slot_id' => 10]);
|
$request = new \WP_REST_Request(['slot_id' => 10]);
|
||||||
$result = $this->endpoint->book($request);
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||||
self::assertNull($result->get_data()['payment']);
|
}
|
||||||
|
|
||||||
|
public function testBookRejectsInactiveStudentChosenOffering(): void
|
||||||
|
{
|
||||||
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Retired', price: 50.0, isActive: false, id: 8)
|
||||||
|
);
|
||||||
|
$this->availability->shouldNotReceive('claim');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||||
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_offering', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookRejectsGroupClassOfferingForLessonSlot(): void
|
||||||
|
{
|
||||||
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 10.0, id: 8)
|
||||||
|
);
|
||||||
|
$this->availability->shouldNotReceive('claim');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||||
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_offering', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookRejectsOfferingWhoseDurationDoesNotFitSlot(): void
|
||||||
|
{
|
||||||
|
// Slot is 60 minutes (the helper default); a 30-minute offering cannot book it.
|
||||||
|
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Short', price: 25.0, durationMinutes: 30, id: 8)
|
||||||
|
);
|
||||||
|
$this->availability->shouldNotReceive('claim');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||||
|
$result = $this->endpoint->book($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('offering_mismatch', $result->get_error_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBookWithPricedOfferingStaysPendingAndReturnsPaymentSummary(): void
|
public function testBookWithPricedOfferingStaysPendingAndReturnsPaymentSummary(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user