Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c611268bdb | ||
|
|
721c4be1d6
|
||
|
|
3aa65bad06 | ||
|
|
f3ba09b195 |
@@ -11,6 +11,13 @@ 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.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
|
||||||
|
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
|
||||||
|
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
|
||||||
|
|
||||||
## [1.2.0]
|
## [1.2.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps front-end-only users (students) out of wp-admin entirely.
|
||||||
|
*
|
||||||
|
* Students authenticate through the front-end login shortcode and do all of
|
||||||
|
* their work — booking, viewing lessons, paying — on the site's public pages.
|
||||||
|
* They have no reason to see the WordPress dashboard, profile screen, or admin
|
||||||
|
* bar, so this guard redirects them to the front end if they reach wp-admin and
|
||||||
|
* hides the admin bar for them everywhere.
|
||||||
|
*
|
||||||
|
* Access is decided by capability, not role: anyone holding a back-office
|
||||||
|
* capability (a WordPress administrator, studio admin, or instructor) keeps full
|
||||||
|
* wp-admin access, while a user with none of them is treated as front-end only.
|
||||||
|
*/
|
||||||
|
class StudentAdminGuard {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capabilities that grant a genuine reason to be in wp-admin. A user holding
|
||||||
|
* none of these is front-end only and is kept out of the dashboard.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const BACK_OFFICE_CAPS = [
|
||||||
|
'manage_options',
|
||||||
|
RoleManager::CAP_MANAGE_INSTRUCTORS,
|
||||||
|
RoleManager::CAP_MANAGE_STUDENTS,
|
||||||
|
RoleManager::CAP_MANAGE_OFFERINGS,
|
||||||
|
RoleManager::CAP_MANAGE_QUESTIONS,
|
||||||
|
RoleManager::CAP_MANAGE_POLICIES,
|
||||||
|
RoleManager::CAP_MANAGE_BILLING,
|
||||||
|
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||||
|
RoleManager::CAP_VIEW_ALL_LESSONS,
|
||||||
|
RoleManager::CAP_VIEW_ALL_PAYMENTS,
|
||||||
|
RoleManager::CAP_VIEW_OWN_PAYMENTS,
|
||||||
|
RoleManager::CAP_EXPORT_PAYMENTS,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', [ $this, 'redirectFromDashboard' ] );
|
||||||
|
add_filter( 'show_admin_bar', [ $this, 'hideAdminBar' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect a front-end-only user away from any wp-admin page to the site
|
||||||
|
* home, so the dashboard and profile screens are never reachable.
|
||||||
|
*/
|
||||||
|
public function redirectFromDashboard(): void {
|
||||||
|
if ( ! $this->shouldBlockAdminAccess() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_safe_redirect( home_url( '/' ) );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current request into wp-admin should be bounced to the front
|
||||||
|
* end. AJAX requests are always allowed through so front-end features that
|
||||||
|
* call admin-ajax keep working.
|
||||||
|
*/
|
||||||
|
public function shouldBlockAdminAccess(): bool {
|
||||||
|
if ( wp_doing_ajax() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_user_logged_in() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->hasBackOfficeAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the admin bar for front-end-only users; leave it untouched for anyone
|
||||||
|
* with back-office access.
|
||||||
|
*
|
||||||
|
* @param bool $show Whether WordPress would otherwise show the admin bar.
|
||||||
|
*/
|
||||||
|
public function hideAdminBar( bool $show ): bool {
|
||||||
|
if ( is_user_logged_in() && ! $this->hasBackOfficeAccess() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $show;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current user holds any capability that warrants wp-admin access.
|
||||||
|
*/
|
||||||
|
private function hasBackOfficeAccess(): bool {
|
||||||
|
foreach ( self::BACK_OFFICE_CAPS as $cap ) {
|
||||||
|
if ( current_user_can( $cap ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,15 @@ class Offering {
|
|||||||
*/
|
*/
|
||||||
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
|
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
|
||||||
|
|
||||||
|
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
|
||||||
|
public const MAX_TITLE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
|
||||||
|
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
|
||||||
|
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $instructorId,
|
public readonly int $instructorId,
|
||||||
public readonly string $kind,
|
public readonly string $kind,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular\Offering;
|
namespace Unsupervised\Schedular\Offering;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ class OfferingController {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private OfferingRepository $repository,
|
private OfferingRepository $repository,
|
||||||
private ClassSlotReconciler $reconciler,
|
private ClassSlotReconciler $reconciler,
|
||||||
|
private AccessSettings $access = new AccessSettings(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -137,15 +139,26 @@ class OfferingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered instructors offered in the assignment select, by display name.
|
* Instructors offered in the assignment select, by display name.
|
||||||
|
*
|
||||||
|
* Includes everyone holding the `us_instructor` role plus, when the site owner
|
||||||
|
* has left administrators acting as instructors (the default single-account
|
||||||
|
* setup), WordPress administrators — who teach through the dynamic capability
|
||||||
|
* grant rather than the role. Without them a solo studio owner running the
|
||||||
|
* business from an admin account would find no one to assign a class to.
|
||||||
*
|
*
|
||||||
* @return list<array{id: int, name: string}>
|
* @return list<array{id: int, name: string}>
|
||||||
*/
|
*/
|
||||||
private function instructorOptions(): array {
|
private function instructorOptions(): array {
|
||||||
|
$roles = [ RoleManager::INSTRUCTOR ];
|
||||||
|
if ( $this->access->adminsAreInstructors() ) {
|
||||||
|
$roles[] = 'administrator';
|
||||||
|
}
|
||||||
|
|
||||||
$users = array_filter(
|
$users = array_filter(
|
||||||
get_users(
|
get_users(
|
||||||
[
|
[
|
||||||
'role' => RoleManager::INSTRUCTOR,
|
'role__in' => $roles,
|
||||||
'orderby' => 'display_name',
|
'orderby' => 'display_name',
|
||||||
'order' => 'ASC',
|
'order' => 'ASC',
|
||||||
]
|
]
|
||||||
@@ -184,6 +197,17 @@ class OfferingController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scheduleNote = $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) );
|
||||||
|
$etransferEmail = $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
|
||||||
|
|
||||||
|
// Reject over-long fixed-size fields rather than let the DB silently drop them.
|
||||||
|
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|
||||||
|
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|
||||||
|
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
||||||
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
|
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
|
||||||
$billingMode = Offering::BILLING_ONE_TIME;
|
$billingMode = Offering::BILLING_ONE_TIME;
|
||||||
@@ -234,8 +258,8 @@ class OfferingController {
|
|||||||
classTime: $classTime,
|
classTime: $classTime,
|
||||||
enrollmentDeadline: $enrollmentDeadline,
|
enrollmentDeadline: $enrollmentDeadline,
|
||||||
withdrawalDeadline: $withdrawalDeadline,
|
withdrawalDeadline: $withdrawalDeadline,
|
||||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $cutoffHours,
|
cancellationCutoffHours: $cutoffHours,
|
||||||
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
||||||
isActive: isset( $_POST['is_active'] ),
|
isActive: isset( $_POST['is_active'] ),
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ class OfferingEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) );
|
||||||
|
$etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) );
|
||||||
|
|
||||||
|
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
|
||||||
|
if ( $lengthError instanceof \WP_Error ) {
|
||||||
|
return $lengthError;
|
||||||
|
}
|
||||||
|
|
||||||
$offering = new Offering(
|
$offering = new Offering(
|
||||||
instructorId: get_current_user_id(),
|
instructorId: get_current_user_id(),
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
@@ -162,8 +170,8 @@ class OfferingEndpoint {
|
|||||||
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
||||||
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
||||||
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
||||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
||||||
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
|
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
|
||||||
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
||||||
@@ -196,10 +204,19 @@ class OfferingEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title;
|
||||||
|
$scheduleNote = $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote;
|
||||||
|
$etransferEmail = $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail;
|
||||||
|
|
||||||
|
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
|
||||||
|
if ( $lengthError instanceof \WP_Error ) {
|
||||||
|
return $lengthError;
|
||||||
|
}
|
||||||
|
|
||||||
$offering = new Offering(
|
$offering = new Offering(
|
||||||
instructorId: $existing->instructorId,
|
instructorId: $existing->instructorId,
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title,
|
title: $title,
|
||||||
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
|
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
|
||||||
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
|
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
|
||||||
billingMode: $billingMode,
|
billingMode: $billingMode,
|
||||||
@@ -210,8 +227,8 @@ class OfferingEndpoint {
|
|||||||
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
||||||
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
||||||
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
||||||
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
||||||
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
|
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
|
||||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||||
@@ -266,6 +283,34 @@ class OfferingEndpoint {
|
|||||||
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject any fixed-size field whose value exceeds its column length, so an
|
||||||
|
* over-long value is refused with a clear 400 rather than silently dropped
|
||||||
|
* by the database.
|
||||||
|
*/
|
||||||
|
private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error {
|
||||||
|
$fields = [
|
||||||
|
[ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ],
|
||||||
|
[ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ],
|
||||||
|
[ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $fields as [ $name, $value, $max ] ) {
|
||||||
|
if ( null !== $value && mb_strlen( $value ) > $max ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: field name, 2: maximum character count. */
|
||||||
|
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
$name,
|
||||||
|
$max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function price( mixed $value ): float {
|
private function price( mixed $value ): float {
|
||||||
return max( 0.0, Val::float( $value ) );
|
return max( 0.0, Val::float( $value ) );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentAdminGuard;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
@@ -96,6 +97,7 @@ class Plugin {
|
|||||||
( new UpdateChecker() )->register();
|
( new UpdateChecker() )->register();
|
||||||
( new RoleManager() )->register();
|
( new RoleManager() )->register();
|
||||||
( new RegistrationLoginGate() )->register();
|
( new RegistrationLoginGate() )->register();
|
||||||
|
( new StudentAdminGuard() )->register();
|
||||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
|
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
|
||||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
|
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class Policy {
|
|||||||
*/
|
*/
|
||||||
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
|
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
|
||||||
|
|
||||||
|
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
|
||||||
|
public const MAX_TITLE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the slug, matching the `slug` VARCHAR(191) column. */
|
||||||
|
public const MAX_SLUG_LENGTH = 191;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $title,
|
public readonly string $title,
|
||||||
public readonly string $slug,
|
public readonly string $slug,
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class PolicyController {
|
|||||||
$scope = Policy::SCOPE_BOOKING;
|
$scope = Policy::SCOPE_BOOKING;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) {
|
$withinLimits = mb_strlen( $title ) <= Policy::MAX_TITLE_LENGTH && mb_strlen( $slug ) <= Policy::MAX_SLUG_LENGTH;
|
||||||
|
|
||||||
|
if ( '' !== $title && '' !== $slug && $withinLimits && null === $this->policies->findBySlug( $slug ) ) {
|
||||||
$this->service->createPolicy( $title, $slug, $scope );
|
$this->service->createPolicy( $title, $slug, $scope );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,12 +118,30 @@ class PolicyEndpoint {
|
|||||||
if ( '' === $title ) {
|
if ( '' === $title ) {
|
||||||
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: maximum character count. */
|
||||||
|
__( 'The policy title must be %d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
Policy::MAX_TITLE_LENGTH
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
|
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
|
||||||
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
|
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
|
||||||
if ( '' === $slug ) {
|
if ( '' === $slug ) {
|
||||||
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $slug ) > Policy::MAX_SLUG_LENGTH ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: maximum character count. */
|
||||||
|
__( 'The policy slug must be %d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
Policy::MAX_SLUG_LENGTH
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ( null !== $this->policies->findBySlug( $slug ) ) {
|
if ( null !== $this->policies->findBySlug( $slug ) ) {
|
||||||
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class Question {
|
|||||||
public const FIELD_SELECT = 'select';
|
public const FIELD_SELECT = 'select';
|
||||||
public const FIELD_CHECKBOX = 'checkbox';
|
public const FIELD_CHECKBOX = 'checkbox';
|
||||||
|
|
||||||
|
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
|
||||||
|
public const MAX_LABEL_LENGTH = 255;
|
||||||
|
|
||||||
/** Question is scoped to a single offering, asked at booking/enrolment time. */
|
/** Question is scoped to a single offering, asked at booking/enrolment time. */
|
||||||
public const SCOPE_OFFERING = 'offering';
|
public const SCOPE_OFFERING = 'offering';
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class QuestionController {
|
|||||||
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
|
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
|
||||||
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
|
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
|
||||||
|
|
||||||
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ class QuestionEndpoint {
|
|||||||
if ( '' === $label ) {
|
if ( '' === $label ) {
|
||||||
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
||||||
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||||
|
}
|
||||||
|
|
||||||
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
||||||
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
||||||
@@ -118,9 +121,17 @@ class QuestionEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$label = $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label;
|
||||||
|
if ( '' === $label ) {
|
||||||
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
||||||
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||||
|
}
|
||||||
|
|
||||||
$question = new Question(
|
$question = new Question(
|
||||||
offeringId: $existing->offeringId,
|
offeringId: $existing->offeringId,
|
||||||
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
|
label: $label,
|
||||||
fieldType: $fieldType,
|
fieldType: $fieldType,
|
||||||
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
||||||
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
||||||
@@ -217,4 +228,16 @@ class QuestionEndpoint {
|
|||||||
private function invalid( string $message ): \WP_Error {
|
private function invalid( string $message ): \WP_Error {
|
||||||
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a uniform "too long" validation message for a named field.
|
||||||
|
*/
|
||||||
|
private function tooLongMessage( string $field, int $max ): string {
|
||||||
|
return sprintf(
|
||||||
|
/* translators: 1: field name, 2: maximum character count. */
|
||||||
|
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
$field,
|
||||||
|
$max
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
<td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_TITLE_LENGTH); ?>" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
||||||
@@ -140,11 +140,11 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="us-group-only">
|
<tr class="us-group-only">
|
||||||
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_SCHEDULE_NOTE_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_ETRANSFER_EMAIL_LENGTH); ?>" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required></td>
|
<td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" required></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" name="slug" id="slug" class="regular-text" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
|
<input type="text" name="slug" id="slug" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_SLUG_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="label" id="label" class="regular-text" required></td>
|
<td><input type="text" name="label" id="label" class="regular-text" maxlength="<?php echo esc_attr((string) Question::MAX_LABEL_LENGTH); ?>" required></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentAdminGuard;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class StudentAdminGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
private StudentAdminGuard $guard;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->guard = new StudentAdminGuard();
|
||||||
|
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $held Capabilities the user is treated as holding.
|
||||||
|
*/
|
||||||
|
private function stubUser(bool $loggedIn, array $held = []): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn($loggedIn);
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => in_array($cap, $held, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlocksStudentWithNoBackOfficeCapabilities(): void
|
||||||
|
{
|
||||||
|
// A student holds only front-end capabilities.
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON, RoleManager::CAP_VIEW_LESSONS]);
|
||||||
|
|
||||||
|
self::assertTrue($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllowsInstructor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllowsAdministrator(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, ['manage_options']);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotBlockLoggedOutRequests(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotBlockAjaxRequests(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_doing_ajax')->justReturn(true);
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHidesAdminBarForStudent(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->hideAdminBar(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsAdminBarForInstructor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
|
||||||
|
|
||||||
|
self::assertTrue($this->guard->hideAdminBar(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLeavesAdminBarUntouchedForLoggedOutVisitor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->hideAdminBar(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ class OfferingControllerTest extends TestCase
|
|||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
Functions\when('get_current_user_id')->justReturn(3);
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
Functions\when('get_users')->justReturn([]);
|
Functions\when('get_users')->justReturn([]);
|
||||||
|
// Default single-account setup: admins act as instructors.
|
||||||
|
Functions\when('get_option')->justReturn('1');
|
||||||
Functions\when('check_admin_referer')->justReturn(true);
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
||||||
Functions\when('add_query_arg')->alias(
|
Functions\when('add_query_arg')->alias(
|
||||||
@@ -450,6 +452,51 @@ class OfferingControllerTest extends TestCase
|
|||||||
self::assertStringNotContainsString('Edit Offering', $html);
|
self::assertStringNotContainsString('Edit Offering', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInstructorPickerIncludesAdministratorsWhenTheyActAsInstructors(): void
|
||||||
|
{
|
||||||
|
// The reported bug: a solo studio owner runs the business from a WordPress
|
||||||
|
// administrator account and teaches through the dynamic capability grant,
|
||||||
|
// so they never hold the us_instructor role. The picker must still list
|
||||||
|
// them, otherwise there is no one to assign a class to.
|
||||||
|
Functions\when('get_option')->justReturn('1');
|
||||||
|
|
||||||
|
$admin = Mockery::mock(\WP_User::class);
|
||||||
|
$admin->ID = 3;
|
||||||
|
$admin->display_name = 'Studio Owner';
|
||||||
|
|
||||||
|
$queriedRoles = [];
|
||||||
|
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles, $admin): array {
|
||||||
|
$queriedRoles = $args['role__in'];
|
||||||
|
return [$admin];
|
||||||
|
});
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertContains('us_instructor', $queriedRoles);
|
||||||
|
self::assertContains('administrator', $queriedRoles);
|
||||||
|
self::assertStringContainsString('Studio Owner', $html);
|
||||||
|
self::assertStringContainsString('<option value="3"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstructorPickerExcludesAdministratorsWhenGrantDisabled(): void
|
||||||
|
{
|
||||||
|
// With the "admins are instructors" toggle off, an admin is not a teacher,
|
||||||
|
// so only the explicit us_instructor role is queried.
|
||||||
|
Functions\when('get_option')->justReturn('0');
|
||||||
|
|
||||||
|
$queriedRoles = null;
|
||||||
|
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles): array {
|
||||||
|
$queriedRoles = $args['role__in'];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
|
||||||
|
self::assertSame(['us_instructor'], $queriedRoles);
|
||||||
|
}
|
||||||
|
|
||||||
private function render(): string
|
private function render(): string
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|||||||
@@ -118,4 +118,37 @@ class OfferingEndpointTest extends TestCase
|
|||||||
|
|
||||||
self::assertArrayNotHasKey('etransfer_email', $data[0]);
|
self::assertArrayNotHasKey('etransfer_email', $data[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_email')->returnArg();
|
||||||
|
$this->repository->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'title' => str_repeat('a', Offering::MAX_TITLE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_offering']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsScheduleNoteLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_email')->returnArg();
|
||||||
|
$this->repository->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'title' => 'Choir',
|
||||||
|
'schedule_note' => str_repeat('a', Offering::MAX_SCHEDULE_NOTE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_offering']['status']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Policy;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyService;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class PolicyEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
private PolicyRepository&Mockery\MockInterface $policies;
|
||||||
|
private PolicyService&Mockery\MockInterface $service;
|
||||||
|
private PolicyEndpoint $endpoint;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_title')->returnArg();
|
||||||
|
|
||||||
|
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||||
|
$this->service = Mockery::mock(PolicyService::class);
|
||||||
|
$this->endpoint = new PolicyEndpoint(
|
||||||
|
$this->policies,
|
||||||
|
Mockery::mock(PolicyVersionRepository::class),
|
||||||
|
$this->service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
$this->service->shouldNotReceive('createPolicy');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_policy']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsSlugLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
$this->service->shouldNotReceive('createPolicy');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'title' => 'Cancellation',
|
||||||
|
'slug' => str_repeat('a', Policy::MAX_SLUG_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_policy']['status']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Registration\Question;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionEndpoint;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class QuestionEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
private QuestionRepository&Mockery\MockInterface $questions;
|
||||||
|
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||||
|
private QuestionEndpoint $endpoint;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when('get_current_user_id')->justReturn(5);
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
|
||||||
|
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||||
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->endpoint = new QuestionEndpoint($this->questions, $this->offerings);
|
||||||
|
|
||||||
|
// The caller (instructor 5) owns offering 9, so the ownership gate passes
|
||||||
|
// and validation is reached.
|
||||||
|
$this->offerings->shouldReceive('findById')->with(9)->andReturn(
|
||||||
|
new Offering(instructorId: 5, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsLabelLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
// The insert must never be attempted for an over-long label — the bug was
|
||||||
|
// that it reached the DB, silently failed, and returned success anyway.
|
||||||
|
$this->questions->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'offering_id' => 9,
|
||||||
|
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_question']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAcceptsLabelAtColumnLimit(): void
|
||||||
|
{
|
||||||
|
$this->questions->shouldReceive('insert')->once()->andReturn(42);
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'offering_id' => 9,
|
||||||
|
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_REST_Response::class, $response);
|
||||||
|
self::assertSame(201, $response->get_status());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.0
|
* Version: 1.2.1
|
||||||
* 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.0');
|
define('USC_VERSION', '1.2.1');
|
||||||
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__));
|
||||||
|
|||||||
Reference in New Issue
Block a user