2 Commits
Author SHA1 Message Date
thatguygriff 1975630136 Name the destination page in the continue link
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
"Continue to your account" says nothing about where the link goes. Use
the chosen page's own title instead — "Continue to Book a Lesson" — so
the visitor knows before clicking.

An untitled page keeps the generic wording rather than rendering
"Continue to ".
2026-07-28 22:58:24 -03:00
thatguygriff f36c235060 Link a signed-in visitor to the configured continue page
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
The registration page's already-logged-in branch returned a bare
sentence with nowhere to go, leaving the visitor to find their own way
to their account. The invited-student branch a few lines above already
built exactly the link that was missing.

Extract that into continueLink() and use it for both logged-in
outcomes. There is deliberately still no wp_login_url() fallback:
sending someone already signed in to the login screen is the same dead
end with extra steps, so with no page configured there is no link.

Both messages now carry the us-register-form wrapper and enqueue the
plugin stylesheet, which the invite branch emitted markup for but never
loaded.

Closes #131
2026-07-28 22:50:10 -03:00
20 changed files with 113 additions and 962 deletions
-7
View File
@@ -11,13 +11,6 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.2.4]
### Fixed
- **Adding availability no longer fails in silence.** Entering a window shorter than the chosen lesson length — 5:306:00 PM with the lesson length left on its default of 60 minutes, say — saved nothing and said nothing: the page just reloaded, whether the window was one-off or set to repeat for 41 weeks. The **Lesson length** menu now offers only the lengths that actually fit the window you have entered, and the form refuses to submit when none of them do. Every other way the form could quietly do nothing now explains itself too — an unreadable date, an end time before the start, a window running past midnight into the next day — and a successful save says how many bookable slots it created. Deleting says whether the slot went, and tells you when one is refused because it is already booked. Availability added through the API is checked against exactly the same rules, which it previously enforced slightly differently.
- A student's **upcoming lessons no longer pile on top of each other**. On the booking page, the lesson name, its date and time, the status badge and the **Cancel** button could render over one another instead of sitting in a tidy row — worst with a long lesson-type name, and on narrow screens, where the row had no phone layout at all. The panel now keeps its shape whatever the theme around it does, long names wrap instead of shoving the Cancel button out of the row, and on a phone the lesson details stack above the buttons.
- The registration page **no longer dead-ends a visitor who is already signed in**. It used to greet them with "You already have an account and are logged in." and nothing else, leaving them to find their own way to the studio. They now get a link onward to the page chosen under the block's **After registration** panel, and the link names it — "Continue to Book a Lesson" rather than the vaguer wording an invited student used to see. With no page chosen, the message appears on its own as before, because sending someone who is already signed in to the sign-in screen helps nobody.
## [1.2.3] ## [1.2.3]
### Changed ### Changed
+16 -57
View File
@@ -34,72 +34,47 @@
margin-top: 8px; margin-top: 8px;
} }
/* .us-my-lessons {
* The upcoming-lessons panel. Every rule here is scoped under #us-booking-app —
* the same id-level specificity .us-slot above uses — because these rows sit in
* whatever layout the theme provides and carry more content than a calendar
* cell. Bare class selectors lost to theme rules on div/span/strong, which
* collapsed the flex layout and piled the details on top of the actions.
*/
#us-booking-app .us-my-lessons {
margin-bottom: 24px; margin-bottom: 24px;
} }
#us-booking-app .us-my-lesson { .us-my-lesson {
box-sizing: border-box;
max-width: 100%;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
padding: 12px 16px; padding: 12px 16px;
margin-bottom: 8px; margin-bottom: 8px;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px 12px; gap: 12px;
} }
/* .us-my-lesson-info {
* `min-width: 0` lets the title column shrink below its content width — without
* it a long offering title cannot compress and shoves the status pill and
* Cancel button out of the row. The flex-basis keeps the details and the
* actions on one line while there is room, and wraps them once there is not.
*/
#us-booking-app .us-my-lesson-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
flex: 1 1 14em;
min-width: 0;
} }
#us-booking-app .us-my-lesson-title, .us-my-lesson-title {
#us-booking-app .us-my-lesson-when {
overflow-wrap: break-word;
word-break: break-word;
}
#us-booking-app .us-my-lesson-title {
font-size: 1.05em; font-size: 1.05em;
} }
#us-booking-app .us-my-lesson-duration { .us-my-lesson-duration {
font-weight: normal; font-weight: normal;
color: #666; color: #666;
} }
#us-booking-app .us-my-lesson-when { .us-my-lesson-when {
color: #555; color: #555;
} }
#us-booking-app .us-my-lesson-actions { .us-my-lesson-actions {
display: flex; display: flex;
flex-wrap: wrap; gap: 12px;
gap: 8px 12px;
align-items: center; align-items: center;
} }
#us-booking-app .us-show-all-lessons { .us-show-all-lessons {
background: transparent; background: transparent;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
@@ -107,11 +82,11 @@
cursor: pointer; cursor: pointer;
} }
#us-booking-app .us-show-all-lessons:hover { .us-show-all-lessons:hover {
border-color: #888; border-color: #888;
} }
#us-booking-app .us-cancel-lesson { .us-cancel-lesson {
background: transparent; background: transparent;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
@@ -120,26 +95,24 @@
color: #c00; color: #c00;
} }
#us-booking-app .us-cancel-lesson:hover { .us-cancel-lesson:hover {
border-color: #c00; border-color: #c00;
} }
#us-booking-app .us-lesson-status { .us-lesson-status {
display: inline-block;
font-size: 0.85em; font-size: 0.85em;
font-weight: 600; font-weight: 600;
padding: 2px 10px; padding: 2px 10px;
border-radius: 10px; border-radius: 10px;
background: #eee; background: #eee;
white-space: nowrap;
} }
#us-booking-app .us-lesson-status-confirmed { .us-lesson-status-confirmed {
background: #e2f5e5; background: #e2f5e5;
color: #1a7d2e; color: #1a7d2e;
} }
#us-booking-app .us-lesson-status-pending { .us-lesson-status-pending {
background: #fdf3d7; background: #fdf3d7;
color: #8a6d1a; color: #8a6d1a;
} }
@@ -372,20 +345,6 @@
.us-week-day { .us-week-day {
min-height: 0; min-height: 0;
} }
/*
* A lesson row carries a title, a date/time, a status pill and a button —
* more than fits one narrow line, so stack the details above the actions
* rather than letting them wrap into each other.
*/
#us-booking-app .us-my-lesson {
flex-direction: column;
align-items: stretch;
}
#us-booking-app .us-my-lesson-info {
flex: 0 0 auto;
}
} }
/* Shown only in block-editor previews (see BlockPreview). */ /* Shown only in block-editor previews (see BlockPreview). */
-98
View File
@@ -1,98 +0,0 @@
/**
* Availability form: keep the lesson-length choices honest.
*
* A window is stored as consecutive lesson-length slots, so one shorter than the
* chosen lesson length holds no slots at all and saves nothing. Picking 5:306:00
* PM while the length select sat on its default of 60 minutes used to do exactly
* that, silently. The server now rejects it with a message; this narrows the
* choices first so the mistake is hard to make.
*
* This is a convenience only — AvailabilityController and the REST endpoint both
* validate the same window server-side regardless of what happens here.
*/
(function () {
'use strict';
const form = document.getElementById('usc-add-availability');
if (!form) return;
const startEl = document.getElementById('start_dt');
const endEl = document.getElementById('end_dt');
const durationEl = document.getElementById('duration_minutes');
const warningEl = document.getElementById('usc-duration-warning');
const submitEl = form.querySelector('input[type="submit"], button[type="submit"]');
if (!startEl || !endEl || !durationEl) return;
/**
* Minutes between the two datetime-local inputs, or 0 when the pair is not a
* usable window yet — empty, unparseable, backwards, or spanning two days
* (which the server rejects on its own terms, with its own message).
*/
function windowMinutes() {
const start = new Date(startEl.value);
const end = new Date(endEl.value);
if (!startEl.value || !endEl.value || isNaN(start) || isNaN(end)) return 0;
if (end <= start) return 0;
if (startEl.value.slice(0, 10) !== endEl.value.slice(0, 10)) return 0;
return Math.round((end - start) / 60000);
}
function refresh() {
const minutes = windowMinutes();
const options = Array.from(durationEl.options);
// No usable window yet: leave every choice alone rather than fighting
// someone part-way through typing a date.
if (minutes === 0) {
options.forEach((option) => {
option.hidden = false;
option.disabled = false;
});
setBlocked(false);
return;
}
let fits = [];
options.forEach((option) => {
const tooLong = Number(option.value) > minutes;
option.hidden = tooLong;
option.disabled = tooLong;
if (!tooLong) fits.push(option);
});
if (fits.length === 0) {
// Nothing bookable fits, so the form cannot produce a single slot.
setBlocked(true);
return;
}
setBlocked(false);
// The selection may have just been hidden — fall back to the longest
// length that still fits, which is what the instructor most likely wants.
if (durationEl.selectedOptions[0] && durationEl.selectedOptions[0].disabled) {
durationEl.value = fits.reduce(
(longest, option) => (Number(option.value) > Number(longest.value) ? option : longest),
fits[0]
).value;
}
}
function setBlocked(blocked) {
if (warningEl) warningEl.hidden = !blocked;
if (submitEl) submitEl.disabled = blocked;
}
startEl.addEventListener('change', refresh);
startEl.addEventListener('input', refresh);
endEl.addEventListener('change', refresh);
endEl.addEventListener('input', refresh);
refresh();
}());
+4 -7
View File
@@ -582,19 +582,16 @@
function lessonRowHtml(l) { function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson'; const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : ''; const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
// The two columns are divs, not spans: as spans the layout only held up
// while the stylesheet's display:flex won, and a theme rule on span
// collapsed the row onto itself.
return ` return `
<div class="us-my-lesson"> <div class="us-my-lesson">
<div class="us-my-lesson-info"> <span class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}</strong> <strong class="us-my-lesson-title">${title}${duration}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span> <span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</div> </span>
<div class="us-my-lesson-actions"> <span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span> <span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button> <button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</div> </span>
</div>`; </div>`;
} }
+7 -49
View File
@@ -25,9 +25,8 @@ A slot's `duration_minutes` is matched against the offering a student picks: a
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive `AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists `duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
one row per chunk. A trailing remainder shorter than the lesson length is one row per chunk. A trailing remainder shorter than the lesson length is
dropped. Windows must start and end on the same day and fit at least one lesson; dropped. Windows must start and end on the same day and fit at least one lesson
both the REST endpoint and the admin form reject one that does not, with a (REST responds `400 invalid_window` otherwise; the admin form is a no-op).
message saying so (see **REST API** below).
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by `AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
`Installer` on activation or version change) that rewrites pre-split rows. `Installer` on activation or version change) that rewrites pre-split rows.
@@ -45,27 +44,6 @@ Instructors access **My Availability** in wp-admin (`?page=us-availability`).
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level - Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar` - Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
### Feedback
Every submitted action reports its outcome as a wp-admin notice — a success
notice naming the number of slots created or deleted, or an error explaining the
refusal. `AvailabilityController::handleFormAction()` returns a
`[$notice, $error]` pair that `templates/admin/availability.php` renders.
This matters because the form used to fail **silently**: a window shorter than
the chosen lesson length splits into no slots, so nothing was written, nothing
was said, and the page simply reloaded. Submitting 5:306:00 PM with the length
select on its 60-minute default was the reported case. Invalid datetimes, an end
before the start, a window spanning two days, and an offering belonging to
another instructor were all silent in the same way.
### Lesson-length choices
`assets/js/availability-admin.js` (enqueued by `AdminMenu::enqueueAssets()` on
this screen only) hides any lesson length longer than the entered window, falls
back to the longest one that still fits when the current pick is hidden, and
disables the submit button when nothing fits. It is a convenience, not a
guarantee — the server validates the same window regardless. The choices come
from `AvailabilitySlot::DURATION_CHOICES`.
## Public Calendar ## Public Calendar
The front-end booking shortcode renders open slots from `GET /availability` The front-end booking shortcode renders open slots from `GET /availability`
either as an agenda-style list grouped by day or as a **weekly calendar** with either as an agenda-style list grouped by day or as a **weekly calendar** with
@@ -84,29 +62,12 @@ with the **Show Only** lesson-type filter — see `lesson-booking.md`.
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime). `GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
Slots whose start has already passed are never returned. Slots whose start has already passed are never returned.
`POST` runs every submitted window — admin form and REST alike — through `POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
`Availability\WindowValidator`, which returns either the window ready to persist `AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
or a `WP_Error`. The REST endpoint returns that error directly (its `status` `datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
data makes it a 400); the admin screen shows `get_error_message()` in a notice. anything else — or an end not after the start — is rejected (REST responds
Sharing one validator is deliberate: the two paths previously checked the same `400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
rules separately, and the admin copy was both laxer (no offering-ownership
check) and mute (a bare `return` on every rejection).
| Rejection | Code |
|---|---|
| Start or end not a real datetime | `invalid_datetime` |
| End at or before the start | `invalid_datetime` |
| Window spans two days | `invalid_window` |
| Window shorter than the lesson length (so it holds no slots) | `invalid_window` |
| Offering missing, or owned by another instructor | `invalid_offering` |
`start_dt`/`end_dt` are normalised by `AvailabilitySlot::normalizeDateTime()`:
the canonical `Y-m-d H:i[:s]` and HTML `datetime-local` (`Y-m-d\TH:i[:s]`) forms
become `Y-m-d H:i:s`; anything else is rejected. A valid window is stored as
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created. lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
`weeks` is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES` in the
repository, so the form's `max` cannot be bypassed by posting directly. A write
that fails entirely returns `500 not_saved` rather than a `201` listing no ids.
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
lists. lists.
@@ -117,8 +78,6 @@ lists.
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar` - Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController` - Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint` - REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
- Shared window validation: `Unsupervised\Schedular\Availability\WindowValidator`
- Admin form script: `assets/js/availability-admin.js`, enqueued by `AdminMenu::enqueueAssets()`
## Tests ## Tests
- `tests/Unit/Availability/AvailabilityControllerTest.php` - `tests/Unit/Availability/AvailabilityControllerTest.php`
@@ -126,4 +85,3 @@ lists.
- `tests/Unit/Availability/AvailabilitySlotTest.php` - `tests/Unit/Availability/AvailabilitySlotTest.php`
- `tests/Unit/Availability/AvailabilityEndpointTest.php` - `tests/Unit/Availability/AvailabilityEndpointTest.php`
- `tests/Unit/Availability/WeekCalendarTest.php` - `tests/Unit/Availability/WeekCalendarTest.php`
- `tests/Unit/Availability/WindowValidatorTest.php`
-9
View File
@@ -154,7 +154,6 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php` - Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
> **Payment seam:** a priced booking is created with `status = pending` and its > **Payment seam:** a priced booking is created with `status = pending` and its
> payment linked via `payment_id`; the lesson is confirmed when the payment is > payment linked via `payment_id`; the lesson is confirmed when the payment is
@@ -162,14 +161,6 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
> Unpriced bookings skip the seam entirely and are confirmed at creation. > Unpriced bookings skip the seam entirely and are confirmed at creation.
> `GET /policies?scope=booking` returns just the booking-gate policies the form > `GET /policies?scope=booking` returns just the booking-gate policies the form
> must collect. > must collect.
>
> **Frontend CSS scoping:** every rule for the booking page's own markup is
> written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit
> inside whatever layout the active theme provides, and bare class selectors lose
> to theme rules on `div`/`span`/`strong` — which flattens the flex layout and
> renders the lesson details on top of the actions. The row's two columns are
> `div`s for the same reason: the layout must not depend on overriding the
> inline default. New booking-page rules should follow both conventions.
## Tests ## Tests
- `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/BookingRepositoryTest.php`
+2 -29
View File
@@ -5,7 +5,6 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Availability\AvailabilityController; use Unsupervised\Schedular\Availability\AvailabilityController;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Auth\AccessSettings; use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\InstructorController; use Unsupervised\Schedular\Auth\InstructorController;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
@@ -43,12 +42,6 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
class AdminMenu { class AdminMenu {
/**
* Hook suffix of the availability screen, captured when the page is added so
* its script loads on that screen only.
*/
private string $availabilityHook = '';
private AvailabilityController $availabilityController; private AvailabilityController $availabilityController;
private LessonController $lessonController; private LessonController $lessonController;
private OfferingController $offeringController; private OfferingController $offeringController;
@@ -65,7 +58,7 @@ class AdminMenu {
private PaymentReportController $paymentReportController; private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) ); $this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) ); $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
@@ -83,29 +76,9 @@ class AdminMenu {
public function register(): void { public function register(): void {
add_action( 'admin_menu', [ $this, 'addPages' ] ); add_action( 'admin_menu', [ $this, 'addPages' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] );
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] ); add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
} }
/**
* Load a screen's script on that screen only.
*
* @param string $hookSuffix Screen the enqueue is running for.
*/
public function enqueueAssets( string $hookSuffix ): void {
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
return;
}
wp_enqueue_script(
'us-scheduler-availability-admin',
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
[],
USC_VERSION,
true
);
}
public function addPages(): void { public function addPages(): void {
$this->addStudioSeparators(); $this->addStudioSeparators();
@@ -121,7 +94,7 @@ class AdminMenu {
); );
// Instructor: manage their own availability. // Instructor: manage their own availability.
$this->availabilityHook = (string) add_menu_page( add_menu_page(
__( 'My Availability', 'unsupervised-schedular' ), __( 'My Availability', 'unsupervised-schedular' ),
__( 'My Availability', 'unsupervised-schedular' ), __( 'My Availability', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_AVAILABILITY, RoleManager::CAP_MANAGE_AVAILABILITY,
+31 -106
View File
@@ -13,7 +13,6 @@ class AvailabilityController {
public function __construct( public function __construct(
private AvailabilityRepository $repository, private AvailabilityRepository $repository,
private OfferingRepository $offerings, private OfferingRepository $offerings,
private WindowValidator $validator,
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -22,11 +21,9 @@ class AvailabilityController {
} }
$instructorId = get_current_user_id(); $instructorId = get_current_user_id();
$notice = '';
$error = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
[ $notice, $error ] = $this->handleFormAction( $instructorId ); $this->handleFormAction( $instructorId );
} }
$slots = $this->repository->findByInstructor( $instructorId ); $slots = $this->repository->findByInstructor( $instructorId );
@@ -47,144 +44,72 @@ class AvailabilityController {
include USC_PLUGIN_DIR . 'templates/admin/availability.php'; include USC_PLUGIN_DIR . 'templates/admin/availability.php';
} }
/** private function handleFormAction( int $instructorId ): void {
* Run the submitted action and report what happened. Every branch returns a
* message: a form that silently reloads leaves the instructor unable to tell
* "saved 41 slots" from "saved nothing".
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleFormAction( int $instructorId ): array {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
return $this->addSlot( $instructorId ); $this->addSlot( $instructorId );
} }
if ( 'delete' === $action ) { if ( 'delete' === $action ) {
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId ) $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
: [ '', __( 'That slot could not be deleted. It may already be booked, or belong to someone else.', 'unsupervised-schedular' ) ];
} }
if ( 'bulk_delete' === $action ) { if ( 'bulk_delete' === $action ) {
// The array itself carries no data; each element is coerced and // The array itself carries no data; each element is coerced and
// absint-sanitized individually below. // absint-sanitized individually below.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$rawIds = $_POST['slot_ids'] ?? []; $rawIds = $_POST['slot_ids'] ?? [];
$deleted = 0;
$failed = 0;
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) { foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
if ( $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ) ) { $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
++$deleted;
continue;
}
++$failed;
} }
return $this->bulkDeleteResult( $deleted, $failed );
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
return [ '', '' ];
}
/**
* Wording for a bulk delete, which can partly succeed.
*
* @return array{string, string}
*/
private function bulkDeleteResult( int $deleted, int $failed ): array {
$notice = $deleted > 0
? sprintf(
/* translators: %d: number of availability slots deleted. */
_n( '%d slot deleted.', '%d slots deleted.', $deleted, 'unsupervised-schedular' ),
$deleted
)
: '';
$error = $failed > 0
? sprintf(
/* translators: %d: number of slots that could not be deleted. */
_n(
'%d slot could not be deleted — it may already be booked.',
'%d slots could not be deleted — they may already be booked.',
$failed,
'unsupervised-schedular'
),
$failed
)
: '';
if ( 0 === $deleted && 0 === $failed ) {
$error = __( 'No slots were selected.', 'unsupervised-schedular' );
}
return [ $notice, $error ];
} }
/** /**
* Delete a slot only when it exists and belongs to the given instructor. * Delete a slot only when it exists and belongs to the given instructor.
* The repository additionally refuses to delete booked slots. Returns whether * The repository additionally refuses to delete booked slots.
* the row actually went away.
*/ */
private function deleteOwnSlot( int $slotId, int $instructorId ): bool { private function deleteOwnSlot( int $slotId, int $instructorId ): void {
if ( $slotId <= 0 ) { if ( $slotId <= 0 ) {
return false; return;
} }
$slot = $this->repository->findById( $slotId ); $slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
if ( null === $slot || $slot->instructorId !== $instructorId ) { $this->repository->delete( $slotId );
return false;
} }
return $this->repository->delete( $slotId );
} }
/** private function addSlot( int $instructorId ): void {
* Validate and persist a submitted window.
*
* @return array{string, string}
*/
private function addSlot( int $instructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$window = $this->validator->validate( $startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
$instructorId, $endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ),
absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ),
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
);
if ( $window instanceof \WP_Error ) { // A window must start and end on the same day (weekly repeat covers longer
return [ '', $window->get_error_message() ]; // ranges) and fit at least one lesson; it is stored as lesson-length slots.
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return;
} }
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration > 0 ? $duration : 60,
offeringId: $offeringId > 0 ? $offeringId : null,
);
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ); $recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) ); $weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
$ids = $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
// The window was valid, so it split into at least one slot — an empty
// result means every insert failed.
if ( [] === $ids ) {
return [ '', __( 'The availability could not be saved. Please try again.', 'unsupervised-schedular' ) ];
}
return [
sprintf(
/* translators: %d: number of bookable slots created. */
_n( 'Added %d bookable slot.', 'Added %d bookable slots.', count( $ids ), 'unsupervised-schedular' ),
count( $ids )
),
'',
];
} }
} }
+34 -17
View File
@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Availability; namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
class AvailabilityEndpoint { class AvailabilityEndpoint {
public function __construct( public function __construct(
private AvailabilityRepository $repository, private AvailabilityRepository $repository,
private WindowValidator $validator, private OfferingRepository $offerings,
) {} ) {}
/** /**
@@ -112,18 +113,40 @@ class AvailabilityEndpoint {
} }
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
// Validation lives in WindowValidator so this endpoint and the admin form $instructorId = get_current_user_id();
// enforce exactly the same rules. $offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$window = $this->validator->validate( $duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
get_current_user_id(),
Val::string( $request->get_param( 'start_dt' ) ), // A slot may only be tied to an offering the instructor owns, so it can
Val::string( $request->get_param( 'end_dt' ) ), // never inherit another instructor's price or payment routing at booking.
absint( Val::int( $request->get_param( 'duration_minutes' ) ) ), if ( $offeringId > 0 ) {
absint( Val::int( $request->get_param( 'offering_id' ) ) ), $offering = $this->offerings->findById( $offeringId );
if ( null === $offering || $offering->instructorId !== $instructorId ) {
return new \WP_Error( 'invalid_offering', __( 'That offering is not available.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration > 0 ? $duration : 60,
offeringId: $offeringId > 0 ? $offeringId : null,
); );
if ( $window instanceof \WP_Error ) { if ( [] === $window->splitByDuration() ) {
return $window; return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
} }
$ids = $this->repository->createFromWindow( $ids = $this->repository->createFromWindow(
@@ -132,12 +155,6 @@ class AvailabilityEndpoint {
absint( Val::int( $request->get_param( 'weeks' ) ) ) absint( Val::int( $request->get_param( 'weeks' ) ) )
); );
// A valid window splits into at least one slot, so nothing written means
// every insert failed.
if ( [] === $ids ) {
return new \WP_Error( 'not_saved', __( 'The availability could not be saved.', 'unsupervised-schedular' ), [ 'status' => 500 ] );
}
return new \WP_REST_Response( [ 'ids' => $ids ], 201 ); return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
} }
+6 -31
View File
@@ -11,14 +11,8 @@ class AvailabilityRepository {
$this->table = $db->prefix . 'us_availability'; $this->table = $db->prefix . 'us_availability';
} }
/**
* Insert one slot row. Returns its id, or 0 when the write failed —
* `insert_id` still holds the *previous* statement's id after a failed
* insert, so returning it unconditionally made a failed write look like a
* successful one.
*/
public function insert( AvailabilitySlot $slot ): int { public function insert( AvailabilitySlot $slot ): int {
$written = $this->db->insert( $this->db->insert(
$this->table, $this->table,
[ [
'instructor_id' => $slot->instructorId, 'instructor_id' => $slot->instructorId,
@@ -33,7 +27,7 @@ class AvailabilityRepository {
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ] [ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
); );
return false === $written ? 0 : $this->db->insert_id; return $this->db->insert_id;
} }
/** /**
@@ -48,17 +42,9 @@ class AvailabilityRepository {
$ids = []; $ids = [];
foreach ( $window->splitByDuration() as $slot ) { foreach ( $window->splitByDuration() as $slot ) {
if ( $weekly ) { $ids = $weekly
$ids = array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) ); ? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
continue; : [ ...$ids, $this->insert( $slot ) ];
}
$id = $this->insert( $slot );
// A failed insert returns 0; it must not reach the caller as an id.
if ( $id > 0 ) {
$ids[] = $id;
}
} }
return $ids; return $ids;
@@ -69,14 +55,10 @@ class AvailabilityRepository {
* separate row one week apart, all sharing a `recurrence_group` (the id of the * separate row one week apart, all sharing a `recurrence_group` (the id of the
* first row). * first row).
* *
* The count is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES`. The
* form's `max` attribute says the same, but only this is binding — a
* hand-crafted POST used to be able to ask for an unbounded number of rows.
*
* @return list<int> Inserted slot IDs. * @return list<int> Inserted slot IDs.
*/ */
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array { public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $occurrences ) ); $occurrences = max( 1, $occurrences );
$start = new \DateTimeImmutable( $first->startDt ); $start = new \DateTimeImmutable( $first->startDt );
$end = new \DateTimeImmutable( $first->endDt ); $end = new \DateTimeImmutable( $first->endDt );
@@ -97,13 +79,6 @@ class AvailabilityRepository {
) )
); );
// A failed insert returns 0. Skipping it keeps a bogus id out of the
// returned list and, more importantly, stops 0 becoming the series'
// recurrence group — which would orphan every later occurrence.
if ( $id <= 0 ) {
continue;
}
if ( 0 === $groupId ) { if ( 0 === $groupId ) {
$groupId = $id; $groupId = $id;
$this->setRecurrenceGroup( $id, $groupId ); $this->setRecurrenceGroup( $id, $groupId );
-17
View File
@@ -7,23 +7,6 @@ use Unsupervised\Schedular\Val;
class AvailabilitySlot { class AvailabilitySlot {
/** Lesson length used when none was submitted. */
public const DEFAULT_DURATION_MINUTES = 60;
/**
* Lesson lengths a window can be split into, offered by the availability
* form. The form hides the ones a given window is too short for.
*
* @var list<int>
*/
public const DURATION_CHOICES = [ 30, 60 ];
/**
* Ceiling on a weekly series, matching the form's `max`. Enforced in the
* repository too, so a hand-crafted POST cannot ask for ten thousand rows.
*/
public const MAX_WEEKLY_OCCURRENCES = 52;
public function __construct( public function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $startDt, public readonly string $startDt,
-105
View File
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Offering\OfferingRepository;
/**
* Validates a submitted availability window.
*
* The admin form and the REST endpoint both accept the same window, and used to
* check it independently — the endpoint returning a specific 400 for each
* failure while the form simply returned, saving nothing and saying nothing. A
* 30-minute window submitted with the default 60-minute lesson length was the
* visible symptom: no rows, no error, no clue. Both callers now come through
* here, so neither can drift from the other again.
*
* Every rejection is a `WP_Error` carrying a message written for the person who
* submitted the form: the endpoint returns it as-is (the `status` data makes it
* a 400), and the admin screen shows `get_error_message()` in a notice.
*/
class WindowValidator {
public function __construct( private OfferingRepository $offerings ) {}
/**
* Check a submitted window and return it ready to persist.
*
* @param int $instructorId Instructor the window belongs to.
* @param string $rawStart Submitted start, in any form {@see AvailabilitySlot::normalizeDateTime()} accepts.
* @param string $rawEnd Submitted end, likewise.
* @param int $durationMinutes Lesson length the window is split into; 0 falls back to the 60-minute default.
* @param int $offeringId Offering the slots are tied to, or 0 for any private lesson.
*
* @return AvailabilitySlot|\WP_Error The window, or why it was rejected.
*/
public function validate( int $instructorId, string $rawStart, string $rawEnd, int $durationMinutes, int $offeringId ): AvailabilitySlot|\WP_Error {
$startDt = AvailabilitySlot::normalizeDateTime( $rawStart );
$endDt = AvailabilitySlot::normalizeDateTime( $rawEnd );
if ( null === $startDt || null === $endDt ) {
return new \WP_Error(
'invalid_datetime',
__( 'Enter a valid start and end date and time.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
if ( $endDt <= $startDt ) {
return new \WP_Error(
'invalid_datetime',
__( 'The end time must be after the start time.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return new \WP_Error(
'invalid_window',
__( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
// A slot may only be tied to an offering the instructor owns, so it can
// never inherit another instructor's price or payment routing at booking.
if ( $offeringId > 0 ) {
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || $offering->instructorId !== $instructorId ) {
return new \WP_Error(
'invalid_offering',
__( 'That offering is not available.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
}
$duration = $durationMinutes > 0 ? $durationMinutes : AvailabilitySlot::DEFAULT_DURATION_MINUTES;
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration,
offeringId: $offeringId > 0 ? $offeringId : null,
);
// The window is stored as lesson-length slots, so one that cannot fit a
// single lesson would persist nothing at all.
if ( [] === $window->splitByDuration() ) {
return new \WP_Error(
'invalid_window',
sprintf(
/* translators: %d: the selected lesson length, in minutes. */
__( 'This window is shorter than the %d-minute lesson length, so it holds no bookable slots. Choose a shorter lesson length or a longer window.', 'unsupervised-schedular' ),
$duration
),
[ 'status' => 400 ]
);
}
return $window;
}
}
+4 -4
View File
@@ -86,13 +86,13 @@ class BlockPreview {
private static function upcomingLessons(): string { private static function upcomingLessons(): string {
return sprintf( return sprintf(
'<div class="us-my-lessons"><h3>%s</h3>' '<div class="us-my-lessons"><h3>%s</h3>'
. '<div class="us-my-lesson"><div class="us-my-lesson-info">' . '<div class="us-my-lesson"><span class="us-my-lesson-info">'
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>' . '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
. '<span class="us-my-lesson-when">%s</span></div>' . '<span class="us-my-lesson-when">%s</span></span>'
. '<div class="us-my-lesson-actions">' . '<span class="us-my-lesson-actions">'
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>' . '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>' . '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
. '</div></div></div>', . '</span></div></div>',
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ), esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ), esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
esc_html__( 'Monday · 4:00 PM4:30 PM', 'unsupervised-schedular' ), esc_html__( 'Monday · 4:00 PM4:30 PM', 'unsupervised-schedular' ),
+1 -2
View File
@@ -5,7 +5,6 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Availability\AvailabilityEndpoint; use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\Booking\CancellationPolicy;
@@ -38,7 +37,7 @@ class RestRegistrar {
private PaymentEndpoint $paymentEndpoint; private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) ); $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
+4 -26
View File
@@ -13,12 +13,8 @@ if (! defined('ABSPATH')) {
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays * @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
* @var string $prevWeek * @var string $prevWeek
* @var string $nextWeek * @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.
*/ */
use Unsupervised\Schedular\Availability\AvailabilitySlot;
$baseUrl = admin_url('admin.php?page=us-availability'); $baseUrl = admin_url('admin.php?page=us-availability');
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void { $deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
@@ -37,16 +33,9 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if ('' !== $error) : ?>
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2> <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> <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"> <form method="post">
<?php wp_nonce_field('usc_availability_action'); ?> <?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="add"> <input type="hidden" name="usc_action" value="add">
<table class="form-table"> <table class="form-table">
@@ -62,20 +51,9 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
<th><label for="duration_minutes"><?php esc_html_e('Lesson length (minutes)', 'unsupervised-schedular'); ?></label></th> <th><label for="duration_minutes"><?php esc_html_e('Lesson length (minutes)', 'unsupervised-schedular'); ?></label></th>
<td> <td>
<select name="duration_minutes" id="duration_minutes"> <select name="duration_minutes" id="duration_minutes">
<?php foreach (AvailabilitySlot::DURATION_CHOICES as $choice) : ?> <option value="30">30</option>
<option value="<?php echo esc_attr((string) $choice); ?>" <?php selected($choice, AvailabilitySlot::DEFAULT_DURATION_MINUTES); ?>> <option value="60" selected>60</option>
<?php echo esc_html((string) $choice); ?>
</option>
<?php endforeach; ?>
</select> </select>
<?php
// Filled and revealed by availability-admin.js when the entered
// window is too short for every lesson length. The wording lives
// here so it stays translatable.
?>
<p class="description usc-duration-warning" id="usc-duration-warning" hidden>
<?php esc_html_e('This window is too short for any lesson length. Lengthen the window or it cannot be saved.', 'unsupervised-schedular'); ?>
</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -95,7 +73,7 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
<label><input type="radio" name="recurrence" value="single" checked> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label> <label><input type="radio" name="recurrence" value="single" checked> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
&nbsp; &nbsp;
<label><input type="radio" name="recurrence" value="weekly"> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label> <label><input type="radio" name="recurrence" value="weekly"> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
<input type="number" name="weeks" min="1" max="<?php echo esc_attr((string) AvailabilitySlot::MAX_WEEKLY_OCCURRENCES); ?>" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?> <input type="number" name="weeks" min="1" max="52" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?>
</td> </td>
</tr> </tr>
</table> </table>
@@ -8,7 +8,6 @@ use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityController; use Unsupervised\Schedular\Availability\AvailabilityController;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -24,7 +23,7 @@ class AvailabilityControllerTest extends TestCase
$this->repository = Mockery::mock(AvailabilityRepository::class); $this->repository = Mockery::mock(AvailabilityRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings)); $this->controller = new AvailabilityController($this->repository, $this->offerings);
$_POST = []; $_POST = [];
$_GET = []; $_GET = [];
@@ -40,7 +39,6 @@ class AvailabilityControllerTest extends TestCase
Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
Functions\when('get_option')->justReturn(1); Functions\when('get_option')->justReturn(1);
Functions\when('current_time')->justReturn('2026-07-06'); Functions\when('current_time')->justReturn('2026-07-06');
Functions\when('selected')->justReturn('');
Functions\when('admin_url')->justReturn('admin.php?page=us-availability'); Functions\when('admin_url')->justReturn('admin.php?page=us-availability');
Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week'); Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week');
Functions\when('wp_nonce_field')->justReturn(''); Functions\when('wp_nonce_field')->justReturn('');
@@ -133,159 +131,6 @@ class AvailabilityControllerTest extends TestCase
self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html); self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html);
} }
/**
* The reported bug: a 30-minute window submitted with the lesson length left
* on 60 saved nothing and said nothing. It must now say why.
*/
public function testAddShowsAnErrorWhenTheWindowIsShorterThanTheLessonLength(): void
{
$_POST = [
'usc_action' => 'add',
'start_dt' => '2026-09-10T17:30',
'end_dt' => '2026-09-10T18:00',
'duration_minutes' => '60',
];
$this->repository->shouldNotReceive('createFromWindow');
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-error', $html);
self::assertStringContainsString('60-minute lesson length', $html);
self::assertStringNotContainsString('notice-success', $html);
}
/** @return array<string, array{array<string, string>, string}> */
public static function invalidWindows(): array
{
return [
'unparseable start' => [['start_dt' => 'whenever', 'end_dt' => '2026-09-10T18:00'], 'valid start and end'],
'end before start' => [['start_dt' => '2026-09-10T18:00', 'end_dt' => '2026-09-10T17:00'], 'after the start time'],
'spans two days' => [['start_dt' => '2026-09-10T23:00', 'end_dt' => '2026-09-11T01:00'], 'same day'],
];
}
/**
* Each of these used to be a bare `return` — the page reloaded unchanged and
* the instructor had no way to tell the save had failed.
*
* @dataProvider invalidWindows
*
* @param array<string, string> $fields
*/
public function testAddReportsEveryRejectedWindow(array $fields, string $expected): void
{
$_POST = array_merge([ 'usc_action' => 'add', 'duration_minutes' => '30' ], $fields);
$this->repository->shouldNotReceive('createFromWindow');
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-error', $html);
self::assertStringContainsString($expected, $html);
}
public function testAddRejectsAnOfferingTheInstructorDoesNotOwn(): void
{
// The REST endpoint always checked this; the admin form never did, so a
// crafted POST could tie a slot to another instructor's offering.
$_POST = [
'usc_action' => 'add',
'start_dt' => '2026-09-10T17:00',
'end_dt' => '2026-09-10T18:00',
'duration_minutes' => '60',
'offering_id' => '8',
];
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
$this->repository->shouldNotReceive('createFromWindow');
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-error', $html);
self::assertStringContainsString('not available', $html);
}
public function testAddReportsHowManySlotsWereCreated(): void
{
$_POST = [
'usc_action' => 'add',
'start_dt' => '2026-09-10T17:00',
'end_dt' => '2026-09-10T19:00',
'duration_minutes' => '60',
'recurrence' => 'weekly',
'weeks' => '41',
];
$this->repository->shouldReceive('createFromWindow')->once()
->with(Mockery::type(AvailabilitySlot::class), true, 41)
->andReturn(range(1, 82));
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-success', $html);
self::assertStringContainsString('Added 82 bookable slots.', $html);
}
public function testAddReportsAFailedWrite(): void
{
// A valid window always splits into at least one slot, so an empty result
// means the inserts themselves failed.
$_POST = [
'usc_action' => 'add',
'start_dt' => '2026-09-10T17:00',
'end_dt' => '2026-09-10T18:00',
'duration_minutes' => '60',
];
$this->repository->shouldReceive('createFromWindow')->once()->andReturn([]);
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-error', $html);
self::assertStringContainsString('could not be saved', $html);
}
public function testSingleDeleteReportsAFailureToDelete(): void
{
$_POST = [ 'usc_action' => 'delete', 'slot_id' => '7' ];
$other = new AvailabilitySlot(instructorId: 4, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 7);
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($other);
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('notice-error', $html);
self::assertStringContainsString('could not be deleted', $html);
}
public function testBulkDeleteReportsBothHalvesOfAPartialResult(): void
{
$_POST = [ 'usc_action' => 'bulk_delete', 'slot_ids' => ['5', '7'] ];
$owned = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 5);
$booked = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 10:00:00', endDt: '2026-07-08 11:00:00', isBooked: true, id: 7);
$this->repository->shouldReceive('findById')->once()->with(5)->andReturn($owned);
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($booked);
$this->repository->shouldReceive('delete')->once()->with(5)->andReturn(true);
// The repository refuses a booked row, reporting it by returning false.
$this->repository->shouldReceive('delete')->once()->with(7)->andReturn(false);
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('1 slot deleted.', $html);
self::assertStringContainsString('1 slot could not be deleted', $html);
}
private function render(): string private function render(): string
{ {
ob_start(); ob_start();
@@ -8,7 +8,6 @@ use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityEndpoint; use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -27,7 +26,7 @@ class AvailabilityEndpointTest extends TestCase
$this->repository = Mockery::mock(AvailabilityRepository::class); $this->repository = Mockery::mock(AvailabilityRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->endpoint = new AvailabilityEndpoint($this->repository, new WindowValidator($this->offerings)); $this->endpoint = new AvailabilityEndpoint($this->repository, $this->offerings);
} }
public function testCreateRejectsWindowSpanningMultipleDays(): void public function testCreateRejectsWindowSpanningMultipleDays(): void
@@ -49,101 +49,6 @@ class AvailabilityRepositoryTest extends TestCase
self::assertSame(42, $result); self::assertSame(42, $result);
} }
public function testInsertReturnsZeroWhenTheWriteFails(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
// wpdb::insert returns false on error, but insert_id still holds the
// previous statement's id — returning it made a failed write look like a
// successful one.
$this->db->shouldReceive('insert')->once()->andReturn(false);
$this->db->insert_id = 42;
$slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30);
self::assertSame(0, $this->repo->insert($slot));
}
public function testCreateFromWindowOmitsChunksThatFailedToInsert(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// Three chunks; the middle write fails.
$results = [null, false, null];
$ids = [11, 13];
$this->db->shouldReceive('insert')
->times(3)
->andReturnUsing(function () use (&$results, &$ids) {
$outcome = array_shift($results);
if (false !== $outcome) {
$this->db->insert_id = array_shift($ids);
}
return $outcome;
});
$window = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:30:00', 30);
self::assertSame([11, 13], $this->repo->createFromWindow($window));
}
public function testWeeklySeriesIsClampedToTheMaximum(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// The form's max is advisory; a hand-crafted POST could ask for any
// number, so the ceiling is enforced here.
$next = 1;
$this->db->shouldReceive('insert')
->times(AvailabilitySlot::MAX_WEEKLY_OCCURRENCES)
->andReturnUsing(function () use (&$next) {
$this->db->insert_id = $next++;
return null;
});
$this->db->shouldReceive('update')->once();
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
self::assertCount(
AvailabilitySlot::MAX_WEEKLY_OCCURRENCES,
$this->repo->createWeeklySeries($first, 10000)
);
}
public function testWeeklySeriesGroupsOnTheFirstRowThatActuallyWrote(): void
{
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
// The first insert fails. A failed row must not become the recurrence
// group (its id is 0), which would orphan every later occurrence.
$results = [false, null, null];
$ids = [21, 22];
$this->db->shouldReceive('insert')
->times(3)
->andReturnUsing(function () use (&$results, &$ids) {
$outcome = array_shift($results);
if (false !== $outcome) {
$this->db->insert_id = array_shift($ids);
}
return $outcome;
});
// The group is set from row 21 — the first that survived.
$this->db->shouldReceive('update')
->once()
->with('wp_us_availability', ['recurrence_group' => 21], ['id' => 21], ['%d'], ['%d']);
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
self::assertSame([21, 22], $this->repo->createWeeklySeries($first, 3));
}
public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void
{ {
Functions\when('current_time')->justReturn('2026-04-07 12:00:00'); Functions\when('current_time')->justReturn('2026-04-07 12:00:00');
@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Availability;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class WindowValidatorTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $offerings;
private WindowValidator $validator;
protected function setUp(): void
{
parent::setUp();
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->validator = new WindowValidator($this->offerings);
}
/**
* The reported bug: 5:306:00 PM submitted with the length select left on
* its 60-minute default. The window fits no lesson, so it used to persist
* nothing at all and say nothing.
*/
public function testRejectsAWindowShorterThanTheLessonLength(): void
{
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 60, 0);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_window', $result->get_error_code());
// The message names the length actually chosen, so the fix is obvious.
self::assertStringContainsString('60-minute', $result->get_error_message());
}
public function testAcceptsThatSameWindowAtAFittingLessonLength(): void
{
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 30, 0);
self::assertInstanceOf(AvailabilitySlot::class, $result);
self::assertSame('2026-09-10 17:30:00', $result->startDt);
self::assertSame('2026-09-10 18:00:00', $result->endDt);
self::assertSame(30, $result->durationMinutes);
self::assertNull($result->offeringId);
self::assertCount(1, $result->splitByDuration());
}
/** @return array<string, array{string, string}> */
public static function badDateTimes(): array
{
return [
'empty start' => ['', '2026-09-10 18:00'],
'empty end' => ['2026-09-10 17:00', ''],
'unparseable start' => ['tomorrow', '2026-09-10 18:00'],
'unparseable end' => ['2026-09-10 17:00', 'not a date'],
];
}
/** @dataProvider badDateTimes */
public function testRejectsUnusableDateTimes(string $start, string $end): void
{
$result = $this->validator->validate(3, $start, $end, 30, 0);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_datetime', $result->get_error_code());
}
public function testRejectsAnEndAtOrBeforeTheStart(): void
{
$backwards = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 17:00', 30, 0);
$identical = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 18:00', 30, 0);
self::assertInstanceOf(\WP_Error::class, $backwards);
self::assertInstanceOf(\WP_Error::class, $identical);
self::assertSame('invalid_datetime', $backwards->get_error_code());
self::assertSame('invalid_datetime', $identical->get_error_code());
}
public function testRejectsAWindowSpanningTwoDays(): void
{
$result = $this->validator->validate(3, '2026-09-10 23:00', '2026-09-11 01:00', 30, 0);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_window', $result->get_error_code());
self::assertStringContainsString('same day', $result->get_error_message());
}
public function testRejectsAnOfferingOwnedBySomeoneElse(): void
{
// Instructor 3 posting instructor 9's offering: accepting it would let a
// slot inherit another instructor's price and payment routing.
$this->offerings->shouldReceive('findById')->once()->with(8)
->andReturn(new Offering(instructorId: 9, title: 'Theirs', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_offering', $result->get_error_code());
}
public function testRejectsAnOfferingThatDoesNotExist(): void
{
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_offering', $result->get_error_code());
}
public function testKeepsAnOfferingTheInstructorOwns(): void
{
$this->offerings->shouldReceive('findById')->once()->with(8)
->andReturn(new Offering(instructorId: 3, title: 'Mine', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
self::assertInstanceOf(AvailabilitySlot::class, $result);
self::assertSame(8, $result->offeringId);
}
public function testFallsBackToTheDefaultLessonLength(): void
{
$result = $this->validator->validate(3, '2026-09-10 09:00', '2026-09-10 10:00', 0, 0);
self::assertInstanceOf(AvailabilitySlot::class, $result);
self::assertSame(AvailabilitySlot::DEFAULT_DURATION_MINUTES, $result->durationMinutes);
}
public function testAcceptsTheDatetimeLocalFormTheFormActuallySubmits(): void
{
// The browser posts `Y-m-d\TH:i`, not the canonical space-separated form.
$result = $this->validator->validate(3, '2026-09-10T17:00', '2026-09-10T18:00', 60, 0);
self::assertInstanceOf(AvailabilitySlot::class, $result);
self::assertSame('2026-09-10 17:00:00', $result->startDt);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.2.4 * Version: 1.2.3
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.2.4'); define('USC_VERSION', '1.2.3');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));