diff --git a/CLAUDE.md b/CLAUDE.md index 4fd3e2e..3814baf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ All database access goes through repository classes within their domain package. | `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes | | `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes | | `BlockPreview` | Static editor-preview markup for the blocks | +| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) | | `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps | | `Auth\LoginPage` | Renders front-end student login form | | `Availability\AvailabilitySlot` | Immutable value object for a slot row | @@ -108,6 +109,6 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp() ### CI Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request: - **lint** — PHPCS WordPress coding standards -- **static-analysis** — PHPStan level 6 +- **static-analysis** — PHPStan level 10 - **test** — PHPUnit on PHP 8.1, 8.2, 8.3 - **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/` diff --git a/composer.json b/composer.json index 6f891cc..ee8851e 100644 --- a/composer.json +++ b/composer.json @@ -11,8 +11,8 @@ "phpunit/phpunit": "^10.5", "brain/monkey": "^2.6", "mockery/mockery": "^1.6", - "phpstan/phpstan": "^1.10", - "szepeviktor/phpstan-wordpress": "^1.3", + "phpstan/phpstan": "^2.0", + "szepeviktor/phpstan-wordpress": "^2.0", "php-stubs/wordpress-stubs": "^6.0", "squizlabs/php_codesniffer": "^3.7", "wp-coding-standards/wpcs": "^3.0" @@ -30,7 +30,7 @@ "scripts": { "test": "phpunit --configuration phpunit.xml", "test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/", - "lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon --memory-limit=1G", + "lint": "phpstan analyse --configuration phpstan.neon --memory-limit=1G", "cs": "phpcs --standard=phpcs.xml.dist", "cs:fix": "phpcbf --standard=phpcs.xml.dist", "build": "bash bin/build-zip.sh" diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 5fda56d..32a4412 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -44,7 +44,32 @@ + + + + + + + + + + + + + + + + - + diff --git a/phpstan.neon b/phpstan.neon index 44b6f3e..5c4121c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,7 @@ includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon parameters: - level: 6 + level: 10 paths: - src bootstrapFiles: diff --git a/src/Auth/AccessSettings.php b/src/Auth/AccessSettings.php index 4e31882..b71056a 100644 --- a/src/Auth/AccessSettings.php +++ b/src/Auth/AccessSettings.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + /** * Site-owner toggles for whether WordPress administrators automatically receive * the studio-admin and/or instructor capabilities. @@ -39,7 +41,7 @@ class AccessSettings { * single-account behaviour. */ private function flag( string $option ): bool { - return '0' !== (string) get_option( $option, '1' ); + return '0' !== Val::string( get_option( $option, '1' ) ); } public function renderPage(): void { diff --git a/src/Auth/InstructorController.php b/src/Auth/InstructorController.php index fceb4a7..02cefba 100644 --- a/src/Auth/InstructorController.php +++ b/src/Auth/InstructorController.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + /** * Studio-admin **Instructors** page: create instructor accounts and toggle each * instructor's managed capabilities. Gated on `manage_instructors`. A studio @@ -24,7 +26,7 @@ class InstructorController { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector. - $instructorId = absint( $_GET['instructor_id'] ?? 0 ); + $instructorId = absint( Val::int( $_GET['instructor_id'] ?? 0 ) ); $instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false; if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) { @@ -50,12 +52,15 @@ class InstructorController { 'email' => $user->user_email, 'registered' => $user->user_registered, ], - get_users( - [ - 'role' => RoleManager::INSTRUCTOR, - 'orderby' => 'display_name', - 'order' => 'ASC', - ] + array_filter( + get_users( + [ + 'role' => RoleManager::INSTRUCTOR, + 'orderby' => 'display_name', + 'order' => 'ASC', + ] + ), + static fn( mixed $user ): bool => $user instanceof \WP_User ) ); @@ -66,7 +71,7 @@ class InstructorController { private function handleFormAction(): string { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( 'create' === $action ) { @@ -82,8 +87,8 @@ class InstructorController { private function createInstructor(): string { // phpcs:disable WordPress.Security.NonceVerification.Missing - $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); - $name = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) ); + $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); + $name = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! is_email( $email ) ) { @@ -125,8 +130,9 @@ class InstructorController { private function updateCaps(): string { // phpcs:disable WordPress.Security.NonceVerification.Missing - $instructorId = absint( $_POST['instructor_id'] ?? 0 ); - $submitted = array_map( 'sanitize_key', (array) wp_unslash( $_POST['capabilities'] ?? [] ) ); + $instructorId = absint( Val::int( $_POST['instructor_id'] ?? 0 ) ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each capability key is sanitized with sanitize_key() in the array_map callback. + $submitted = array_values( array_map( static fn( mixed $cap ): string => sanitize_key( Val::string( $cap ) ), (array) wp_unslash( $_POST['capabilities'] ?? [] ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing $instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false; diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php index c903761..83ad082 100644 --- a/src/Auth/Invite.php +++ b/src/Auth/Invite.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + class Invite { public const STATUS_PENDING = 'pending'; @@ -44,17 +46,17 @@ class Invite { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - email: $row->email, - token: $row->token, - role: $row->role, - status: $row->status, - invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null, - acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null, - acceptedAt: $row->accepted_at, - createdAt: $row->created_at ?? null, - id: (int) $row->id, + email: Val::string( $row->email ), + token: Val::string( $row->token ), + role: Val::string( $row->role ), + status: Val::string( $row->status ), + invitedBy: Val::intOrNull( $row->invited_by ), + acceptedUserId: Val::intOrNull( $row->accepted_user_id ), + acceptedAt: Val::stringOrNull( $row->accepted_at ), + createdAt: Val::stringOrNull( $row->created_at ?? null ), + id: Val::int( $row->id ), ); } diff --git a/src/Auth/InviteRepository.php b/src/Auth/InviteRepository.php index cc08299..80d35ae 100644 --- a/src/Auth/InviteRepository.php +++ b/src/Auth/InviteRepository.php @@ -32,7 +32,7 @@ class InviteRepository { public function findByToken( string $token ): ?Invite { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE token = %s", $token ) + $this->db->prepare( 'SELECT * FROM %i WHERE token = %s', $this->table, $token ) ); return $row ? Invite::fromRow( $row ) : null; @@ -40,7 +40,7 @@ class InviteRepository { public function findById( int $id ): ?Invite { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Invite::fromRow( $row ) : null; @@ -52,7 +52,8 @@ class InviteRepository { public function findPendingByEmail( string $email ): ?Invite { $row = $this->db->get_row( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1", + 'SELECT * FROM %i WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1', + $this->table, $email, Invite::STATUS_PENDING ) @@ -69,7 +70,8 @@ class InviteRepository { public function findPending(): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC", + 'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC', + $this->table, Invite::STATUS_PENDING ) ); diff --git a/src/Auth/LoginPage.php b/src/Auth/LoginPage.php index 01c8b21..90cef34 100644 --- a/src/Auth/LoginPage.php +++ b/src/Auth/LoginPage.php @@ -3,12 +3,14 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + class LoginPage { /** * Renders the student login shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $atts Shortcode attributes (unused — reserved for future options). */ public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found if ( is_user_logged_in() ) { @@ -26,9 +28,9 @@ class LoginPage { if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) { $credentials = [ - 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ), + 'user_login' => sanitize_user( Val::string( wp_unslash( $_POST['log'] ?? '' ) ) ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. - 'user_password' => wp_unslash( $_POST['pwd'] ?? '' ), + 'user_password' => Val::string( wp_unslash( $_POST['pwd'] ?? '' ) ), 'remember' => isset( $_POST['rememberme'] ), ]; diff --git a/src/Auth/RegistrationController.php b/src/Auth/RegistrationController.php index 7ee70f0..402d420 100644 --- a/src/Auth/RegistrationController.php +++ b/src/Auth/RegistrationController.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + class RegistrationController { /** @@ -23,7 +25,7 @@ class RegistrationController { } $pendingInvites = $this->invites->findPending(); - $registrationPageId = (int) get_option( self::OPTION_PAGE, 0 ); + $registrationPageId = Val::int( get_option( self::OPTION_PAGE, 0 ) ); $registrationPageUrl = $registrationPageId > 0 ? (string) get_permalink( $registrationPageId ) : ''; include USC_PLUGIN_DIR . 'templates/admin/invites.php'; @@ -37,14 +39,14 @@ class RegistrationController { private function handleFormAction(): string { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'set_page' === $action ) { - update_option( self::OPTION_PAGE, absint( $_POST['registration_page_id'] ?? 0 ) ); + update_option( self::OPTION_PAGE, absint( Val::int( $_POST['registration_page_id'] ?? 0 ) ) ); } if ( 'invite' === $action ) { - $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); + $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); if ( is_email( $email ) @@ -66,7 +68,7 @@ class RegistrationController { } if ( 'revoke' === $action ) { - $inviteId = absint( $_POST['invite_id'] ?? 0 ); + $inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) ); if ( $inviteId > 0 ) { $this->invites->revoke( $inviteId ); } @@ -80,7 +82,7 @@ class RegistrationController { * Build the registration URL for a raw invite token. */ private function registrationLink( string $rawToken ): string { - $pageId = (int) get_option( self::OPTION_PAGE, 0 ); + $pageId = Val::int( get_option( self::OPTION_PAGE, 0 ) ); $linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : ''; return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) ); diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 1e313cf..ec6e90a 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -8,6 +8,7 @@ use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyVersionRepository; +use Unsupervised\Schedular\Val; class RegistrationPage { @@ -21,7 +22,7 @@ class RegistrationPage { /** * Renders the student registration shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $atts Shortcode attributes (unused — reserved for future options). */ public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found if ( is_user_logged_in() ) { @@ -29,7 +30,7 @@ class RegistrationPage { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below. - $token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ); + $token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) ); // Only the token's hash is stored, so hash the submitted token for lookup. $invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null; @@ -64,12 +65,12 @@ class RegistrationPage { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target. - $token = sanitize_text_field( wp_unslash( $_GET['us_invite'] ?? '' ) ); + $token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) ); if ( '' === $token ) { return; } - $pageId = (int) get_option( RegistrationController::OPTION_PAGE, 0 ); + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); if ( $pageId <= 0 || is_page( $pageId ) ) { return; } @@ -90,15 +91,16 @@ class RegistrationPage { // The submit nonce is verified by the caller (render) before this runs. // phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. - $password = (string) wp_unslash( $_POST['password'] ?? '' ); - $displayName = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) ); + $password = Val::string( wp_unslash( $_POST['password'] ?? '' ) ); + $displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) ); if ( strlen( $password ) < 8 ) { return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' ); } $policyForms = $this->signupPolicies(); - $accepted = array_map( 'absint', (array) ( $_POST['accept'] ?? [] ) ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion. + $accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing foreach ( $policyForms as $form ) { @@ -141,7 +143,7 @@ class RegistrationPage { */ private function recordAcceptances( array $policyForms, int $userId ): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit. - $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); foreach ( $policyForms as $form ) { $this->acceptances->insert( diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php index 0e35983..39ba93b 100644 --- a/src/Auth/StudentController.php +++ b/src/Auth/StudentController.php @@ -11,6 +11,7 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\Payment; +use Unsupervised\Schedular\Val; class StudentController { @@ -28,7 +29,7 @@ class StudentController { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector. - $studentId = absint( $_GET['student_id'] ?? 0 ); + $studentId = absint( Val::int( $_GET['student_id'] ?? 0 ) ); $student = $studentId > 0 ? get_userdata( $studentId ) : false; if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) { @@ -45,12 +46,15 @@ class StudentController { 'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ), 'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ), ], - get_users( - [ - 'role' => RoleManager::STUDENT, - 'orderby' => 'display_name', - 'order' => 'ASC', - ] + array_filter( + get_users( + [ + 'role' => RoleManager::STUDENT, + 'orderby' => 'display_name', + 'order' => 'ASC', + ] + ), + static fn( mixed $user ): bool => $user instanceof \WP_User ) ); @@ -63,7 +67,7 @@ class StudentController { if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. - $method = sanitize_key( wp_unslash( $_POST['payment_method'] ?? '' ) ); + $method = sanitize_key( Val::string( wp_unslash( $_POST['payment_method'] ?? '' ) ) ); if ( in_array( $method, Payment::VALID_METHODS, true ) ) { update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method ); } else { @@ -71,7 +75,7 @@ class StudentController { } } - $billingOverride = (string) get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ); + $billingOverride = Val::string( get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ) ); $billingDefault = $this->resolver->defaultMethod(); $now = current_time( 'mysql' ); diff --git a/src/Auth/StudentSchedule.php b/src/Auth/StudentSchedule.php index 280bf60..522705f 100644 --- a/src/Auth/StudentSchedule.php +++ b/src/Auth/StudentSchedule.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Val; + /** * Pure helper for splitting a student's dated rows into upcoming and past. */ @@ -21,7 +23,7 @@ class StudentSchedule { $past = []; foreach ( $rows as $row ) { - $start = (string) ( $row['start_dt'] ?? '' ); + $start = Val::string( $row['start_dt'] ?? '' ); if ( '' !== $start && $start >= $now ) { $upcoming[] = $row; } else { @@ -29,12 +31,12 @@ class StudentSchedule { } } - usort( $upcoming, static fn( array $a, array $b ): int => strcmp( (string) ( $a['start_dt'] ?? '' ), (string) ( $b['start_dt'] ?? '' ) ) ); - usort( $past, static fn( array $a, array $b ): int => strcmp( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) ); + usort( $upcoming, static fn( array $a, array $b ): int => strcmp( Val::string( $a['start_dt'] ?? '' ), Val::string( $b['start_dt'] ?? '' ) ) ); + usort( $past, static fn( array $a, array $b ): int => strcmp( Val::string( $b['start_dt'] ?? '' ), Val::string( $a['start_dt'] ?? '' ) ) ); return [ - 'upcoming' => array_values( $upcoming ), - 'past' => array_values( $past ), + 'upcoming' => $upcoming, + 'past' => $past, ]; } } diff --git a/src/Availability/AvailabilityController.php b/src/Availability/AvailabilityController.php index 9e8d792..43df163 100644 --- a/src/Availability/AvailabilityController.php +++ b/src/Availability/AvailabilityController.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Availability; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Val; class AvailabilityController { @@ -34,14 +35,14 @@ class AvailabilityController { private function handleFormAction( int $instructorId ): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { $this->addSlot( $instructorId ); } if ( 'delete' === $action ) { - $slotId = absint( $_POST['slot_id'] ?? 0 ); + $slotId = absint( Val::int( $_POST['slot_id'] ?? 0 ) ); if ( $slotId > 0 ) { $slot = $this->repository->findById( $slotId ); if ( $slot && $slot->instructorId === $instructorId ) { @@ -54,15 +55,15 @@ class AvailabilityController { private function addSlot( int $instructorId ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing - $startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ) ); - $endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ) ); + $startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) ); + $endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) ); if ( null === $startDt || null === $endDt || $endDt <= $startDt ) { return; } - $offeringId = absint( $_POST['offering_id'] ?? 0 ); - $duration = absint( $_POST['duration_minutes'] ?? 0 ); + $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); + $duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); $slot = new AvailabilitySlot( instructorId: $instructorId, @@ -72,8 +73,8 @@ class AvailabilityController { offeringId: $offeringId > 0 ? $offeringId : null, ); - if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) { - $this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) ); + if ( 'weekly' === sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) ) { + $this->repository->createWeeklySeries( $slot, absint( Val::int( $_POST['weeks'] ?? 1 ) ) ); return; } diff --git a/src/Availability/AvailabilityEndpoint.php b/src/Availability/AvailabilityEndpoint.php index 1398e67..ef25fd5 100644 --- a/src/Availability/AvailabilityEndpoint.php +++ b/src/Availability/AvailabilityEndpoint.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Availability; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Val; class AvailabilityEndpoint { @@ -13,6 +14,11 @@ class AvailabilityEndpoint { private OfferingRepository $offerings, ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -96,11 +102,11 @@ class AvailabilityEndpoint { public function index( \WP_REST_Request $request ): \WP_REST_Response { $slots = $this->repository->findAvailable( - (int) $request->get_param( 'instructor_id' ), - (int) $request->get_param( 'offering_id' ), - (int) $request->get_param( 'duration_minutes' ), - (string) $request->get_param( 'from' ), - (string) $request->get_param( 'to' ), + Val::int( $request->get_param( 'instructor_id' ) ), + Val::int( $request->get_param( 'offering_id' ) ), + Val::int( $request->get_param( 'duration_minutes' ) ), + Val::string( $request->get_param( 'from' ) ), + Val::string( $request->get_param( 'to' ) ), ); return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 ); @@ -108,8 +114,8 @@ class AvailabilityEndpoint { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $instructorId = get_current_user_id(); - $offeringId = absint( $request->get_param( 'offering_id' ) ); - $duration = absint( $request->get_param( 'duration_minutes' ) ); + $offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); + $duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) ); // 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. @@ -120,8 +126,8 @@ class AvailabilityEndpoint { } } - $startDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'start_dt' ) ); - $endDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'end_dt' ) ); + $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 ] ); @@ -136,7 +142,7 @@ class AvailabilityEndpoint { ); if ( 'weekly' === $request->get_param( 'recurrence' ) ) { - $ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) ); + $ids = $this->repository->createWeeklySeries( $slot, absint( Val::int( $request->get_param( 'weeks' ) ) ) ); return new \WP_REST_Response( [ 'ids' => $ids ], 201 ); } @@ -147,7 +153,7 @@ class AvailabilityEndpoint { } public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $slot = $this->repository->findById( $id ); if ( null === $slot ) { diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index 5d6e32b..18b5a13 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -116,11 +116,11 @@ class AvailabilityRepository { } $whereClause = implode( ' AND ', $where ); - $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC"; + $sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC"; - $rows = $params - ? $this->db->get_results( $this->db->prepare( $sql, $params ) ) - : $this->db->get_results( $sql ); + $rows = $this->db->get_results( + $this->db->prepare( $sql, array_merge( [ $this->table ], $params ) ) + ); return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); } @@ -133,7 +133,8 @@ class AvailabilityRepository { public function findByInstructor( int $instructorId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC", + 'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC', + $this->table, $instructorId ) ); @@ -149,7 +150,8 @@ class AvailabilityRepository { public function findUnbookedInGroup( int $recurrenceGroup ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC", + 'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC', + $this->table, $recurrenceGroup ) ); @@ -159,7 +161,7 @@ class AvailabilityRepository { public function findById( int $id ): ?AvailabilitySlot { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? AvailabilitySlot::fromRow( $row ) : null; diff --git a/src/Availability/AvailabilitySlot.php b/src/Availability/AvailabilitySlot.php index 1a6df15..66b729c 100644 --- a/src/Availability/AvailabilitySlot.php +++ b/src/Availability/AvailabilitySlot.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Availability; +use Unsupervised\Schedular\Val; + class AvailabilitySlot { public function __construct( @@ -35,16 +37,16 @@ class AvailabilitySlot { return null; } - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - instructorId: (int) $row->instructor_id, - startDt: $row->start_dt, - endDt: $row->end_dt, - durationMinutes: (int) $row->duration_minutes, - offeringId: null !== $row->offering_id ? (int) $row->offering_id : null, - isBooked: (bool) $row->is_booked, - recurrenceGroup: null !== $row->recurrence_group ? (int) $row->recurrence_group : null, - id: (int) $row->id, + instructorId: Val::int( $row->instructor_id ), + startDt: Val::string( $row->start_dt ), + endDt: Val::string( $row->end_dt ), + durationMinutes: Val::int( $row->duration_minutes ), + offeringId: Val::intOrNull( $row->offering_id ), + isBooked: Val::bool( $row->is_booked ), + recurrenceGroup: Val::intOrNull( $row->recurrence_group ), + id: Val::int( $row->id ), ); } diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 81b1e8b..c17d19d 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -10,6 +10,7 @@ use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; +use Unsupervised\Schedular\Val; class BookingEndpoint { @@ -27,6 +28,11 @@ class BookingEndpoint { private PaymentService $payments, ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -103,7 +109,7 @@ class BookingEndpoint { } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $slotId = (int) $request->get_param( 'slot_id' ); + $slotId = Val::int( $request->get_param( 'slot_id' ) ); $slot = $this->availability->findById( $slotId ); if ( null === $slot ) { @@ -120,7 +126,7 @@ class BookingEndpoint { // used must belong to the slot's instructor. This prevents substituting a // cheaper/free offering to dodge payment, or another instructor's offering // to misroute it. - $requestedOfferingId = absint( $request->get_param( 'offering_id' ) ); + $requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $slotOfferingId = (int) ( $slot->offeringId ?? 0 ); if ( $slotOfferingId > 0 ) { @@ -142,7 +148,7 @@ class BookingEndpoint { } $answers = $this->answers( $request ); - $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) ); + $acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); if ( $gateError instanceof \WP_Error ) { @@ -150,7 +156,7 @@ class BookingEndpoint { } $studentId = get_current_user_id(); - $notes = (string) $request->get_param( 'notes' ); + $notes = Val::string( $request->get_param( 'notes' ) ); $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE; @@ -212,7 +218,7 @@ class BookingEndpoint { private function answers( \WP_REST_Request $request ): array { $out = []; foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { - $out[ (int) $questionId ] = sanitize_text_field( (string) $value ); + $out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) ); } return $out; @@ -220,13 +226,13 @@ class BookingEndpoint { private function clientIp(): ?string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. - $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); return '' !== $ip ? $ip : null; } public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $lesson = $this->bookings->findById( $id ); if ( null === $lesson ) { @@ -237,7 +243,7 @@ class BookingEndpoint { return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } - $this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) ); + $this->bookings->updateStatus( $id, Val::string( $request->get_param( 'status' ) ) ); return new \WP_REST_Response( [ diff --git a/src/Booking/BookingPage.php b/src/Booking/BookingPage.php index 2c5b831..7d9165b 100644 --- a/src/Booking/BookingPage.php +++ b/src/Booking/BookingPage.php @@ -10,14 +10,16 @@ class BookingPage { /** * Renders the booking shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $atts Shortcode attributes (unused — reserved for future options). */ public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found if ( ! is_user_logged_in() ) { + $permalink = get_permalink(); + return sprintf( '

%s %s.

', esc_html__( 'Please', 'unsupervised-schedular' ), - esc_url( wp_login_url( get_permalink() ) ), + esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ), esc_html__( 'log in to book a lesson', 'unsupervised-schedular' ) ); } diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index a8477ad..9f76486 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -80,7 +80,7 @@ class BookingRepository { public function findById( int $id ): ?Lesson { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Lesson::fromRow( $row ) : null; @@ -96,12 +96,14 @@ class BookingRepository { $rows = $this->db->get_results( $this->db->prepare( - "SELECT l.* FROM {$this->table} l - JOIN {$avTable} a ON a.id = l.slot_id + 'SELECT l.* FROM %i l + JOIN %i a ON a.id = l.slot_id WHERE l.instructor_id = %d AND l.status != %s AND a.start_dt >= %s - ORDER BY a.start_dt ASC", + ORDER BY a.start_dt ASC', + $this->table, + $avTable, $instructorId, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) @@ -119,11 +121,13 @@ class BookingRepository { return (int) $this->db->get_var( $this->db->prepare( - "SELECT COUNT(*) FROM {$this->table} l - JOIN {$avTable} a ON a.id = l.slot_id + 'SELECT COUNT(*) FROM %i l + JOIN %i a ON a.id = l.slot_id WHERE l.student_id = %d AND l.status != %s - AND a.start_dt >= %s", + AND a.start_dt >= %s', + $this->table, + $avTable, $studentId, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) @@ -139,7 +143,8 @@ class BookingRepository { public function findByStudent( int $studentId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC", + 'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC', + $this->table, $studentId ) ); @@ -157,11 +162,13 @@ class BookingRepository { $rows = $this->db->get_results( $this->db->prepare( - "SELECT l.* FROM {$this->table} l - JOIN {$avTable} a ON a.id = l.slot_id + 'SELECT l.* FROM %i l + JOIN %i a ON a.id = l.slot_id WHERE l.status != %s AND a.start_dt >= %s - ORDER BY a.start_dt ASC", + ORDER BY a.start_dt ASC', + $this->table, + $avTable, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) ) diff --git a/src/Booking/Lesson.php b/src/Booking/Lesson.php index 073b73d..874efa3 100644 --- a/src/Booking/Lesson.php +++ b/src/Booking/Lesson.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Booking; +use Unsupervised\Schedular\Val; + class Lesson { public const STATUS_PENDING = 'pending'; @@ -39,18 +41,18 @@ class Lesson { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - slotId: (int) $row->slot_id, - studentId: (int) $row->student_id, - instructorId: (int) $row->instructor_id, - offeringId: null !== $row->offering_id ? (int) $row->offering_id : null, - recurrence: $row->recurrence, - seriesId: null !== $row->series_id ? (int) $row->series_id : null, - status: $row->status, - paymentId: null !== $row->payment_id ? (int) $row->payment_id : null, - notes: $row->notes, - id: (int) $row->id, + slotId: Val::int( $row->slot_id ), + studentId: Val::int( $row->student_id ), + instructorId: Val::int( $row->instructor_id ), + offeringId: Val::intOrNull( $row->offering_id ), + recurrence: Val::string( $row->recurrence ), + seriesId: Val::intOrNull( $row->series_id ), + status: Val::string( $row->status ), + paymentId: Val::intOrNull( $row->payment_id ), + notes: Val::stringOrNull( $row->notes ), + id: Val::int( $row->id ), ); } diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index 3a276d0..b83d7b8 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Val; class LessonController { @@ -48,10 +49,11 @@ class LessonController { } // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); - $paymentId = absint( $_POST['payment_id'] ?? 0 ); - $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ); - $taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0; + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ); + $paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); + $email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. + $taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, Val::float( $_POST['tax_rate'] ) ) : 0.0; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) { diff --git a/src/GroupClass/Enrollment.php b/src/GroupClass/Enrollment.php index 896c8af..5b75802 100644 --- a/src/GroupClass/Enrollment.php +++ b/src/GroupClass/Enrollment.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; +use Unsupervised\Schedular\Val; + class Enrollment { public const STATUS_ACTIVE = 'active'; @@ -25,14 +27,14 @@ class Enrollment { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - offeringId: (int) $row->offering_id, - studentId: (int) $row->student_id, - instructorId: (int) $row->instructor_id, - status: $row->status, - paymentId: null !== $row->payment_id ? (int) $row->payment_id : null, - id: (int) $row->id, + offeringId: Val::int( $row->offering_id ), + studentId: Val::int( $row->student_id ), + instructorId: Val::int( $row->instructor_id ), + status: Val::string( $row->status ), + paymentId: Val::intOrNull( $row->payment_id ), + id: Val::int( $row->id ), ); } diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 8f9e069..a4677a9 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -10,6 +10,7 @@ use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; +use Unsupervised\Schedular\Val; class EnrollmentEndpoint { @@ -20,6 +21,11 @@ class EnrollmentEndpoint { private PaymentService $payments, ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -69,7 +75,7 @@ class EnrollmentEndpoint { } public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $offeringId = absint( $request->get_param( 'offering_id' ) ); + $offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $offering = $this->offerings->findById( $offeringId ); if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) { @@ -87,7 +93,7 @@ class EnrollmentEndpoint { } $answers = $this->answers( $request ); - $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) ); + $acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); if ( $gateError instanceof \WP_Error ) { @@ -133,7 +139,7 @@ class EnrollmentEndpoint { private function answers( \WP_REST_Request $request ): array { $out = []; foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { - $out[ (int) $questionId ] = sanitize_text_field( (string) $value ); + $out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) ); } return $out; @@ -141,7 +147,7 @@ class EnrollmentEndpoint { private function clientIp(): ?string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. - $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); return '' !== $ip ? $ip : null; } diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php index c65565a..39672ab 100644 --- a/src/GroupClass/EnrollmentRepository.php +++ b/src/GroupClass/EnrollmentRepository.php @@ -30,7 +30,7 @@ class EnrollmentRepository { public function findById( int $id ): ?Enrollment { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Enrollment::fromRow( $row ) : null; @@ -42,7 +42,8 @@ class EnrollmentRepository { public function countActiveForOffering( int $offeringId ): int { return (int) $this->db->get_var( $this->db->prepare( - "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s", + 'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND status = %s', + $this->table, $offeringId, Enrollment::STATUS_ACTIVE ) @@ -55,7 +56,8 @@ class EnrollmentRepository { public function countActiveForStudent( int $studentId ): int { return (int) $this->db->get_var( $this->db->prepare( - "SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s", + 'SELECT COUNT(*) FROM %i WHERE student_id = %d AND status = %s', + $this->table, $studentId, Enrollment::STATUS_ACTIVE ) @@ -68,7 +70,8 @@ class EnrollmentRepository { public function hasActiveEnrollment( int $offeringId, int $studentId ): bool { $count = (int) $this->db->get_var( $this->db->prepare( - "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s", + 'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status = %s', + $this->table, $offeringId, $studentId, Enrollment::STATUS_ACTIVE @@ -86,7 +89,8 @@ class EnrollmentRepository { public function findByStudent( int $studentId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC", + 'SELECT * FROM %i WHERE student_id = %d ORDER BY enrolled_at DESC', + $this->table, $studentId ) ); @@ -102,7 +106,8 @@ class EnrollmentRepository { public function findByInstructor( int $instructorId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC", + 'SELECT * FROM %i WHERE instructor_id = %d ORDER BY enrolled_at DESC', + $this->table, $instructorId ) ); @@ -118,7 +123,8 @@ class EnrollmentRepository { public function findAllActive(): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC", + 'SELECT * FROM %i WHERE status = %s ORDER BY enrolled_at DESC', + $this->table, Enrollment::STATUS_ACTIVE ) ); diff --git a/src/GroupClass/GroupClassPage.php b/src/GroupClass/GroupClassPage.php index 7ecf423..1188920 100644 --- a/src/GroupClass/GroupClassPage.php +++ b/src/GroupClass/GroupClassPage.php @@ -10,14 +10,16 @@ class GroupClassPage { /** * Renders the group-class enrolment shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $atts Shortcode attributes (unused — reserved for future options). */ public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found if ( ! is_user_logged_in() ) { + $permalink = get_permalink(); + return sprintf( '

%s %s.

', esc_html__( 'Please', 'unsupervised-schedular' ), - esc_url( wp_login_url( get_permalink() ) ), + esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ), esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' ) ); } diff --git a/src/Installer.php b/src/Installer.php index 34a50f9..566113b 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -16,6 +16,9 @@ class Installer { private function createTables(): void { global $wpdb; + if ( ! $wpdb instanceof \wpdb ) { + return; + } $charset = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 8123725..313c174 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Offering; +use Unsupervised\Schedular\Val; + class Offering { public const KIND_PRIVATE_LESSON = 'private_lesson'; @@ -44,24 +46,24 @@ class Offering { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - instructorId: (int) $row->instructor_id, - kind: $row->kind, - title: $row->title, - price: (float) $row->price, - currency: $row->currency, - billingMode: $row->billing_mode, - description: $row->description, - durationMinutes: null !== $row->duration_minutes ? (int) $row->duration_minutes : null, - allowWeekly: (bool) $row->allow_weekly, - capacity: null !== $row->capacity ? (int) $row->capacity : null, - termStart: $row->term_start, - termEnd: $row->term_end, - scheduleNote: $row->schedule_note, - etransferEmail: $row->etransfer_email, - isActive: (bool) $row->is_active, - id: (int) $row->id, + instructorId: Val::int( $row->instructor_id ), + kind: Val::string( $row->kind ), + title: Val::string( $row->title ), + price: Val::float( $row->price ), + currency: Val::string( $row->currency ), + billingMode: Val::string( $row->billing_mode ), + description: Val::stringOrNull( $row->description ), + durationMinutes: Val::intOrNull( $row->duration_minutes ), + allowWeekly: Val::bool( $row->allow_weekly ), + capacity: Val::intOrNull( $row->capacity ), + termStart: Val::stringOrNull( $row->term_start ), + termEnd: Val::stringOrNull( $row->term_end ), + scheduleNote: Val::stringOrNull( $row->schedule_note ), + etransferEmail: Val::stringOrNull( $row->etransfer_email ), + isActive: Val::bool( $row->is_active ), + id: Val::int( $row->id ), ); } diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index 987f95b..87e90cb 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Offering; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class OfferingController { @@ -31,14 +32,14 @@ class OfferingController { private function handleFormAction( int $instructorId, bool $manageAll ): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { $this->addOffering( $instructorId ); } if ( 'delete' === $action ) { - $offeringId = absint( $_POST['offering_id'] ?? 0 ); + $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); if ( $offeringId > 0 ) { $offering = $this->repository->findById( $offeringId ); if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) { @@ -51,33 +52,33 @@ class OfferingController { private function addOffering( int $instructorId ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing - $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ); - $kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) ); + $title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); + $kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) ); if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) { return; } - $billingMode = sanitize_key( 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 ) ) { $billingMode = Offering::BILLING_ONE_TIME; } - $duration = absint( $_POST['duration_minutes'] ?? 0 ); - $capacity = absint( $_POST['capacity'] ?? 0 ); + $duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); + $capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) ); $this->repository->insert( new Offering( instructorId: $instructorId, kind: $kind, title: $title, - price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ), + price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ), billingMode: $billingMode, durationMinutes: $duration > 0 ? $duration : null, allowWeekly: isset( $_POST['allow_weekly'] ), capacity: $capacity > 0 ? $capacity : null, - scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ), - etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ), + 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'] ?? '' ) ) ) ), ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index cc9879c..b37e625 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Offering; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class OfferingEndpoint { public function __construct( private OfferingRepository $repository ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -57,8 +63,8 @@ class OfferingEndpoint { public function index( \WP_REST_Request $request ): \WP_REST_Response { $offerings = $this->repository->findAll( - (int) $request->get_param( 'instructor_id' ), - (string) $request->get_param( 'kind' ), + Val::int( $request->get_param( 'instructor_id' ) ), + Val::string( $request->get_param( 'kind' ) ), activeOnly: true, ); @@ -67,17 +73,17 @@ class OfferingEndpoint { } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); + $title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); if ( '' === $title ) { return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) ); } - $kind = (string) $request->get_param( 'kind' ); + $kind = Val::string( $request->get_param( 'kind' ) ); if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); } - $billingMode = (string) ( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME ); + $billingMode = Val::string( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME ); if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); } @@ -87,7 +93,7 @@ class OfferingEndpoint { kind: $kind, title: $title, price: $this->price( $request->get_param( 'price' ) ), - currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ), + currency: sanitize_text_field( Val::string( $request->get_param( 'currency' ) ?? 'CAD' ) ), billingMode: $billingMode, description: $this->nullableText( $request->get_param( 'description' ) ), durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ), @@ -106,7 +112,7 @@ class OfferingEndpoint { } public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $existing = $this->repository->findById( $id ); if ( null === $existing ) { @@ -117,12 +123,12 @@ class OfferingEndpoint { return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } - $kind = $request->has_param( 'kind' ) ? (string) $request->get_param( 'kind' ) : $existing->kind; + $kind = $request->has_param( 'kind' ) ? Val::string( $request->get_param( 'kind' ) ) : $existing->kind; if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); } - $billingMode = $request->has_param( 'billing_mode' ) ? (string) $request->get_param( 'billing_mode' ) : $existing->billingMode; + $billingMode = $request->has_param( 'billing_mode' ) ? Val::string( $request->get_param( 'billing_mode' ) ) : $existing->billingMode; if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); } @@ -130,9 +136,9 @@ class OfferingEndpoint { $offering = new Offering( instructorId: $existing->instructorId, kind: $kind, - title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title, + title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title, price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, - currency: $request->has_param( 'currency' ) ? sanitize_text_field( (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, description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description, durationMinutes: $request->has_param( 'duration_minutes' ) ? $this->nullableInt( $request->get_param( 'duration_minutes' ) ) : $existing->durationMinutes, @@ -152,7 +158,7 @@ class OfferingEndpoint { } public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $existing = $this->repository->findById( $id ); if ( null === $existing ) { @@ -195,17 +201,17 @@ class OfferingEndpoint { } private function price( mixed $value ): float { - return max( 0.0, (float) $value ); + return max( 0.0, Val::float( $value ) ); } private function nullableEmail( mixed $value ): ?string { - $email = sanitize_email( (string) $value ); + $email = sanitize_email( Val::string( $value ) ); return '' !== $email ? $email : null; } private function nullableInt( mixed $value ): ?int { - return ( null === $value || '' === $value ) ? null : (int) $value; + return ( null === $value || '' === $value ) ? null : Val::int( $value ); } private function nullableText( mixed $value ): ?string { @@ -213,6 +219,6 @@ class OfferingEndpoint { return null; } - return sanitize_text_field( (string) $value ); + return sanitize_text_field( Val::string( $value ) ); } } diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index cfd1f78..a9ea76e 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -90,18 +90,18 @@ class OfferingRepository { } $whereClause = implode( ' AND ', $where ); - $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC"; + $sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC"; - $rows = $params - ? $this->db->get_results( $this->db->prepare( $sql, $params ) ) - : $this->db->get_results( $sql ); + $rows = $this->db->get_results( + $this->db->prepare( $sql, array_merge( [ $this->table ], $params ) ) + ); return array_map( Offering::fromRow( ... ), $rows ?? [] ); } public function findById( int $id ): ?Offering { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Offering::fromRow( $row ) : null; diff --git a/src/Payment/BillingMethodResolver.php b/src/Payment/BillingMethodResolver.php index dac406f..4f2b470 100644 --- a/src/Payment/BillingMethodResolver.php +++ b/src/Payment/BillingMethodResolver.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; +use Unsupervised\Schedular\Val; + /** * Resolves the billing method for a student: a per-student override if set, * otherwise the studio default — card when Stripe is configured, e-transfer when @@ -15,7 +17,7 @@ class BillingMethodResolver { public function __construct( private StudioSettings $settings ) {} public function resolve( int $studentId ): string { - $override = (string) get_user_meta( $studentId, self::META_METHOD, true ); + $override = Val::string( get_user_meta( $studentId, self::META_METHOD, true ) ); if ( in_array( $override, Payment::VALID_METHODS, true ) ) { return $override; } diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index a7ce3a3..b5fe8fb 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; +use Unsupervised\Schedular\Val; + class Payment { public const METHOD_CARD = 'card'; @@ -50,24 +52,24 @@ class Payment { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - studentId: (int) $row->student_id, - instructorId: (int) $row->instructor_id, - registrationType: $row->registration_type, - registrationId: (int) $row->registration_id, - amount: (float) $row->amount, - currency: $row->currency, - method: $row->method, - status: $row->status, - taxRate: (float) $row->tax_rate, - taxAmount: (float) $row->tax_amount, - etransferEmail: $row->etransfer_email, - stripePaymentIntentId: $row->stripe_payment_intent_id, - receiptNumber: $row->receipt_number, - receiptSentAt: $row->receipt_sent_at, - paidAt: $row->paid_at, - id: (int) $row->id, + studentId: Val::int( $row->student_id ), + instructorId: Val::int( $row->instructor_id ), + registrationType: Val::string( $row->registration_type ), + registrationId: Val::int( $row->registration_id ), + amount: Val::float( $row->amount ), + currency: Val::string( $row->currency ), + method: Val::string( $row->method ), + status: Val::string( $row->status ), + taxRate: Val::float( $row->tax_rate ), + taxAmount: Val::float( $row->tax_amount ), + etransferEmail: Val::stringOrNull( $row->etransfer_email ), + stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), + receiptNumber: Val::stringOrNull( $row->receipt_number ), + receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ), + paidAt: Val::stringOrNull( $row->paid_at ), + id: Val::int( $row->id ), ); } diff --git a/src/Payment/PaymentController.php b/src/Payment/PaymentController.php index 6871224..7da12d2 100644 --- a/src/Payment/PaymentController.php +++ b/src/Payment/PaymentController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class PaymentController { @@ -19,10 +20,10 @@ class PaymentController { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. - if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) { + if ( 'mark_paid' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) { // phpcs:disable WordPress.Security.NonceVerification.Missing - $paymentId = absint( $_POST['payment_id'] ?? 0 ); - $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ); + $paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); + $email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( $paymentId > 0 ) { // Record the destination it was actually sent to before confirming. diff --git a/src/Payment/PaymentEndpoint.php b/src/Payment/PaymentEndpoint.php index caef53d..1953819 100644 --- a/src/Payment/PaymentEndpoint.php +++ b/src/Payment/PaymentEndpoint.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class PaymentEndpoint { public function __construct( private PaymentService $service ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -64,8 +70,8 @@ class PaymentEndpoint { * (Stripe client secret for card; display data for e-transfer/comp). */ public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $type = (string) $request->get_param( 'registration_type' ); - $registrationId = absint( $request->get_param( 'registration_id' ) ); + $type = Val::string( $request->get_param( 'registration_type' ) ); + $registrationId = absint( Val::int( $request->get_param( 'registration_id' ) ) ); $result = $this->service->createIntent( $type, $registrationId, get_current_user_id() ); if ( null === $result ) { @@ -99,7 +105,7 @@ class PaymentEndpoint { * Studio admin marks a pending payment (e-transfer) received. */ public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); if ( ! $this->service->markPaid( $id ) ) { return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); diff --git a/src/Payment/PaymentReportController.php b/src/Payment/PaymentReportController.php index 8a9017a..0f3f15d 100644 --- a/src/Payment/PaymentReportController.php +++ b/src/Payment/PaymentReportController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class PaymentReportController { @@ -21,8 +22,8 @@ class PaymentReportController { } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change. - $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' ); - $instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0; + $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' ); + $instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0; // phpcs:enable WordPress.Security.NonceVerification.Recommended $instructorId = $this->scopeInstructor( $instructorId ); @@ -58,8 +59,8 @@ class PaymentReportController { check_admin_referer( self::EXPORT_ACTION ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above. - $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' ); - $instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0; + $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' ); + $instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0; // phpcs:enable WordPress.Security.NonceVerification.Recommended $instructorId = $this->scopeInstructor( $instructorId ); @@ -92,7 +93,8 @@ class PaymentReportController { */ private function buildReport( string $month, int $instructorId ): PaymentReport { $start = $month . '-01 00:00:00'; - $end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) ); + $endTs = strtotime( $month . '-01 00:00:00 +1 month' ); + $end = false === $endTs ? $start : gmdate( 'Y-m-d H:i:s', $endTs ); $rows = array_map( static function ( Payment $payment ): array { diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index 2b81e01..4df4d9a 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -55,7 +55,8 @@ class PaymentRepository { public function findByStripeIntentId( string $intentId ): ?Payment { $row = $this->db->get_row( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1", + 'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1', + $this->table, $intentId ) ); @@ -77,14 +78,15 @@ class PaymentRepository { * Set a payment's tax rate and recompute the tax amount from its subtotal. */ public function updateTax( int $id, float $rate ): bool { - return false !== $this->db->query( - $this->db->prepare( - "UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d", - $rate, - $rate, - $id - ) + $sql = $this->db->prepare( + 'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d', + $this->table, + $rate, + $rate, + $id ); + + return null !== $sql && false !== $this->db->query( $sql ); } /** @@ -94,8 +96,8 @@ class PaymentRepository { * @return list */ public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array { - $sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s"; - $params = [ Payment::STATUS_PAID, $from, $to ]; + $sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s'; + $params = [ $this->table, Payment::STATUS_PAID, $from, $to ]; if ( $instructorId > 0 ) { $sql .= ' AND instructor_id = %d'; @@ -111,7 +113,7 @@ class PaymentRepository { public function findById( int $id ): ?Payment { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Payment::fromRow( $row ) : null; @@ -120,7 +122,8 @@ class PaymentRepository { public function findByRegistration( string $registrationType, int $registrationId ): ?Payment { $row = $this->db->get_row( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1", + 'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1', + $this->table, $registrationType, $registrationId ) @@ -137,7 +140,8 @@ class PaymentRepository { public function findPending(): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC", + 'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC', + $this->table, Payment::STATUS_PENDING ) ); diff --git a/src/Payment/StripeGateway.php b/src/Payment/StripeGateway.php index e139855..763c690 100644 --- a/src/Payment/StripeGateway.php +++ b/src/Payment/StripeGateway.php @@ -67,8 +67,8 @@ class StripeGateway { * Seam around the Stripe PaymentIntents create call so tests can stub the * network request. * - * @param array $params - * @param array $options + * @param array{amount: int, currency: string, metadata: array, description: string} $params + * @param array{idempotency_key?: string} $options */ protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent { return $this->client()->paymentIntents->create( $params, $options ); diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index a2438de..58b8a6d 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Payment; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class StudioSettings { @@ -16,11 +17,11 @@ class StudioSettings { public const OPT_HST_RATE = 'us_hst_rate'; public function publishableKey(): string { - return (string) get_option( self::OPT_PUBLISHABLE, '' ); + return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) ); } public function secretKey(): string { - return (string) get_option( self::OPT_SECRET, '' ); + return Val::string( get_option( self::OPT_SECRET, '' ) ); } /** @@ -28,7 +29,7 @@ class StudioSettings { * webhook requests genuinely came from Stripe. Empty until configured. */ public function webhookSecret(): string { - return (string) get_option( self::OPT_WEBHOOK_SECRET, '' ); + return Val::string( get_option( self::OPT_WEBHOOK_SECRET, '' ) ); } public function mode(): string { @@ -36,7 +37,7 @@ class StudioSettings { } public function currency(): string { - $currency = (string) get_option( self::OPT_CURRENCY, 'CAD' ); + $currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) ); return '' !== $currency ? strtoupper( $currency ) : 'CAD'; } @@ -46,14 +47,14 @@ class StudioSettings { * no override). */ public function etransferEmail(): string { - return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' ); + return Val::string( get_option( self::OPT_ETRANSFER_EMAIL, '' ) ); } /** * Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax. */ public function hstRate(): float { - return max( 0.0, (float) get_option( self::OPT_HST_RATE, 0 ) ); + return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) ); } /** @@ -92,22 +93,23 @@ class StudioSettings { private function save(): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) ); - update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ); + $mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) ); + update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) ); // Secret fields are write-only: a blank submission keeps the stored secret, // so an admin saving other settings never wipes the keys. - $secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ); + $secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) ); if ( '' !== $secretKey ) { update_option( self::OPT_SECRET, $secretKey ); } - $webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) ); + $webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) ); if ( '' !== $webhookSecret ) { update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret ); } update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' ); - update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ); - update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); - $hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0; + update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) ); + update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. + $hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0; update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing } diff --git a/src/Plugin.php b/src/Plugin.php index 41124e4..fb5a59b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -33,6 +33,9 @@ class Plugin { load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' ); global $wpdb; + if ( ! $wpdb instanceof \wpdb ) { + return; + } $availability = new AvailabilityRepository( $wpdb ); $bookings = new BookingRepository( $wpdb ); $offerings = new OfferingRepository( $wpdb ); diff --git a/src/Policy/AcceptanceRepository.php b/src/Policy/AcceptanceRepository.php index a1f51e4..1407604 100644 --- a/src/Policy/AcceptanceRepository.php +++ b/src/Policy/AcceptanceRepository.php @@ -46,7 +46,8 @@ class AcceptanceRepository { public function findByRegistration( string $registrationType, int $registrationId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC", + 'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC', + $this->table, $registrationType, $registrationId ) diff --git a/src/Policy/Policy.php b/src/Policy/Policy.php index fa0f645..dad39fa 100644 --- a/src/Policy/Policy.php +++ b/src/Policy/Policy.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Policy; +use Unsupervised\Schedular\Val; + class Policy { public const SCOPE_SIGNUP = 'signup'; @@ -24,13 +26,13 @@ class Policy { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - title: $row->title, - slug: $row->slug, - currentVersionId: null !== $row->current_version_id ? (int) $row->current_version_id : null, - acceptanceScope: $row->acceptance_scope, - id: (int) $row->id, + title: Val::string( $row->title ), + slug: Val::string( $row->slug ), + currentVersionId: Val::intOrNull( $row->current_version_id ), + acceptanceScope: Val::string( $row->acceptance_scope ), + id: Val::int( $row->id ), ); } diff --git a/src/Policy/PolicyAcceptance.php b/src/Policy/PolicyAcceptance.php index 6c1d9c3..c5ceb3f 100644 --- a/src/Policy/PolicyAcceptance.php +++ b/src/Policy/PolicyAcceptance.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Policy; +use Unsupervised\Schedular\Val; + class PolicyAcceptance { public const REG_ACCOUNT = 'account'; @@ -27,15 +29,15 @@ class PolicyAcceptance { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - policyVersionId: (int) $row->policy_version_id, - studentId: (int) $row->student_id, - registrationType: $row->registration_type, - registrationId: (int) $row->registration_id, - ipAddress: $row->ip_address, - acceptedAt: $row->accepted_at, - id: (int) $row->id, + policyVersionId: Val::int( $row->policy_version_id ), + studentId: Val::int( $row->student_id ), + registrationType: Val::string( $row->registration_type ), + registrationId: Val::int( $row->registration_id ), + ipAddress: Val::stringOrNull( $row->ip_address ), + acceptedAt: Val::stringOrNull( $row->accepted_at ), + id: Val::int( $row->id ), ); } diff --git a/src/Policy/PolicyController.php b/src/Policy/PolicyController.php index aead227..82fb8c6 100644 --- a/src/Policy/PolicyController.php +++ b/src/Policy/PolicyController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Policy; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class PolicyController { @@ -23,7 +24,7 @@ class PolicyController { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector. - $policyId = absint( $_GET['policy_id'] ?? 0 ); + $policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) ); $policyList = $this->policies->findAll(); $selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null; $policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null; @@ -34,13 +35,13 @@ class PolicyController { private function handleFormAction(): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'create_policy' === $action ) { - $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ); - $slugRaw = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) ); + $title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); + $slugRaw = sanitize_text_field( Val::string( wp_unslash( $_POST['slug'] ?? '' ) ) ); $slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title ); - $scope = sanitize_key( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ); + $scope = sanitize_key( Val::string( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ) ); if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { $scope = Policy::SCOPE_BOOKING; @@ -53,18 +54,18 @@ class PolicyController { return; } - $policyId = absint( $_POST['policy_id'] ?? 0 ); + $policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) ); if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) { return; } if ( 'add_version' === $action ) { - $body = wp_kses_post( wp_unslash( $_POST['body'] ?? '' ) ); + $body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ); $this->service->addDraftVersion( $policyId, $body ); } if ( 'publish_version' === $action ) { - $versionId = absint( $_POST['version_id'] ?? 0 ); + $versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) ); if ( $versionId > 0 ) { $this->service->publishVersion( $policyId, $versionId ); } diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index af546a6..b850759 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Policy; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class PolicyEndpoint { @@ -13,6 +14,11 @@ class PolicyEndpoint { private PolicyService $service, ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -74,7 +80,7 @@ class PolicyEndpoint { * `both`-scoped policies). */ public function index( \WP_REST_Request $request ): \WP_REST_Response { - $scope = (string) $request->get_param( 'scope' ); + $scope = Val::string( $request->get_param( 'scope' ) ); $policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true ) ? $this->policies->findForScope( $scope ) : $this->policies->findAll(); @@ -108,12 +114,12 @@ class PolicyEndpoint { } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); + $title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); if ( '' === $title ) { return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) ); } - $slugParam = sanitize_text_field( (string) $request->get_param( 'slug' ) ); + $slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) ); $slug = sanitize_title( '' !== $slugParam ? $slugParam : $title ); if ( '' === $slug ) { return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) ); @@ -123,7 +129,7 @@ class PolicyEndpoint { return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } - $scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING ); + $scope = Val::string( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING ); if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) ); } @@ -134,12 +140,12 @@ class PolicyEndpoint { } public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $policy = $this->policies->findById( absint( $request->get_param( 'id' ) ) ); + $policy = $this->policies->findById( absint( Val::int( $request->get_param( 'id' ) ) ) ); if ( null === $policy ) { return $this->notFound(); } - $body = wp_kses_post( (string) $request->get_param( 'body' ) ); + $body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) ); $id = $this->service->addDraftVersion( (int) $policy->id, $body ); return new \WP_REST_Response( [ 'id' => $id ], 201 ); @@ -155,7 +161,7 @@ class PolicyEndpoint { return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) ); } - $body = wp_kses_post( (string) $request->get_param( 'body' ) ); + $body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) ); $this->versions->updateBody( (int) $version->id, $body ); return new \WP_REST_Response( @@ -173,7 +179,7 @@ class PolicyEndpoint { return $version; } - $this->service->publishVersion( (int) $request->get_param( 'id' ), (int) $version->id ); + $this->service->publishVersion( Val::int( $request->get_param( 'id' ) ), (int) $version->id ); return new \WP_REST_Response( [ @@ -202,8 +208,8 @@ class PolicyEndpoint { * Load the version named in the route and confirm it belongs to the policy. */ private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error { - $policyId = absint( $request->get_param( 'id' ) ); - $version = $this->versions->findById( absint( $request->get_param( 'vid' ) ) ); + $policyId = absint( Val::int( $request->get_param( 'id' ) ) ); + $version = $this->versions->findById( absint( Val::int( $request->get_param( 'vid' ) ) ) ); if ( null === $version || $version->policyId !== $policyId ) { return $this->notFound(); diff --git a/src/Policy/PolicyRepository.php b/src/Policy/PolicyRepository.php index d2af123..428a0ae 100644 --- a/src/Policy/PolicyRepository.php +++ b/src/Policy/PolicyRepository.php @@ -36,7 +36,8 @@ class PolicyRepository { public function findForScope( string $scope ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC", + 'SELECT * FROM %i WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC', + $this->table, $scope, Policy::SCOPE_BOTH ) @@ -68,7 +69,7 @@ class PolicyRepository { public function findById( int $id ): ?Policy { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Policy::fromRow( $row ) : null; @@ -76,7 +77,7 @@ class PolicyRepository { public function findBySlug( string $slug ): ?Policy { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE slug = %s", $slug ) + $this->db->prepare( 'SELECT * FROM %i WHERE slug = %s', $this->table, $slug ) ); return $row ? Policy::fromRow( $row ) : null; diff --git a/src/Policy/PolicyVersion.php b/src/Policy/PolicyVersion.php index f455bdf..e402756 100644 --- a/src/Policy/PolicyVersion.php +++ b/src/Policy/PolicyVersion.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Policy; +use Unsupervised\Schedular\Val; + class PolicyVersion { public const STATUS_DRAFT = 'draft'; @@ -25,14 +27,14 @@ class PolicyVersion { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - policyId: (int) $row->policy_id, - versionNumber: (int) $row->version_number, - body: $row->body, - status: $row->status, - publishedAt: $row->published_at, - id: (int) $row->id, + policyId: Val::int( $row->policy_id ), + versionNumber: Val::int( $row->version_number ), + body: Val::stringOrNull( $row->body ), + status: Val::string( $row->status ), + publishedAt: Val::stringOrNull( $row->published_at ), + id: Val::int( $row->id ), ); } diff --git a/src/Policy/PolicyVersionRepository.php b/src/Policy/PolicyVersionRepository.php index ce46cab..1587cfb 100644 --- a/src/Policy/PolicyVersionRepository.php +++ b/src/Policy/PolicyVersionRepository.php @@ -59,7 +59,8 @@ class PolicyVersionRepository { public function findByPolicy( int $policyId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE policy_id = %d ORDER BY version_number DESC", + 'SELECT * FROM %i WHERE policy_id = %d ORDER BY version_number DESC', + $this->table, $policyId ) ); @@ -69,7 +70,7 @@ class PolicyVersionRepository { public function findById( int $id ): ?PolicyVersion { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? PolicyVersion::fromRow( $row ) : null; @@ -80,7 +81,7 @@ class PolicyVersionRepository { */ public function maxVersionNumber( int $policyId ): int { $max = $this->db->get_var( - $this->db->prepare( "SELECT MAX(version_number) FROM {$this->table} WHERE policy_id = %d", $policyId ) + $this->db->prepare( 'SELECT MAX(version_number) FROM %i WHERE policy_id = %d', $this->table, $policyId ) ); return null === $max ? 0 : (int) $max; diff --git a/src/Registration/Answer.php b/src/Registration/Answer.php index 27c3ff3..3acbac1 100644 --- a/src/Registration/Answer.php +++ b/src/Registration/Answer.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Registration; +use Unsupervised\Schedular\Val; + class Answer { public const REG_LESSON = 'lesson'; @@ -24,14 +26,14 @@ class Answer { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { return new self( - questionId: (int) $row->question_id, - registrationType: $row->registration_type, - registrationId: (int) $row->registration_id, - studentId: (int) $row->student_id, - answerValue: $row->answer_value, - id: (int) $row->id, + questionId: Val::int( $row->question_id ), + registrationType: Val::string( $row->registration_type ), + registrationId: Val::int( $row->registration_id ), + studentId: Val::int( $row->student_id ), + answerValue: Val::stringOrNull( $row->answer_value ), + id: Val::int( $row->id ), ); } diff --git a/src/Registration/AnswerRepository.php b/src/Registration/AnswerRepository.php index 77a157f..daa995b 100644 --- a/src/Registration/AnswerRepository.php +++ b/src/Registration/AnswerRepository.php @@ -46,7 +46,8 @@ class AnswerRepository { public function findByRegistration( string $registrationType, int $registrationId ): array { $rows = $this->db->get_results( $this->db->prepare( - "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC", + 'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC', + $this->table, $registrationType, $registrationId ) diff --git a/src/Registration/Question.php b/src/Registration/Question.php index 055dae4..003a5ea 100644 --- a/src/Registration/Question.php +++ b/src/Registration/Question.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Registration; +use Unsupervised\Schedular\Val; + class Question { public const FIELD_TEXT = 'text'; @@ -38,22 +40,24 @@ class Question { public readonly ?int $id = null, ) {} - public static function fromRow( object $row ): self { + public static function fromRow( \stdClass $row ): self { $options = null; if ( null !== $row->options && '' !== $row->options ) { - $decoded = json_decode( (string) $row->options, true ); - $options = is_array( $decoded ) ? array_values( array_map( 'strval', $decoded ) ) : null; + $decoded = json_decode( Val::string( $row->options ), true ); + $options = is_array( $decoded ) + ? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) ) + : null; } return new self( - offeringId: (int) $row->offering_id, - label: $row->label, - fieldType: $row->field_type, + offeringId: Val::int( $row->offering_id ), + label: Val::string( $row->label ), + fieldType: Val::string( $row->field_type ), options: $options, - isRequired: (bool) $row->is_required, - sortOrder: (int) $row->sort_order, - isActive: (bool) $row->is_active, - id: (int) $row->id, + isRequired: Val::bool( $row->is_required ), + sortOrder: Val::int( $row->sort_order ), + isActive: Val::bool( $row->is_active ), + id: Val::int( $row->id ), ); } diff --git a/src/Registration/QuestionController.php b/src/Registration/QuestionController.php index 6dfeb3b..4659354 100644 --- a/src/Registration/QuestionController.php +++ b/src/Registration/QuestionController.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Registration; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Val; class QuestionController { @@ -23,7 +24,7 @@ class QuestionController { $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector. - $offeringId = absint( $_GET['offering_id'] ?? 0 ); + $offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) ); $offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId ); $selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; @@ -46,14 +47,14 @@ class QuestionController { private function handleFormAction( Offering $offering ): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing - $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { $this->addQuestion( (int) $offering->id ); } if ( 'delete' === $action ) { - $questionId = absint( $_POST['question_id'] ?? 0 ); + $questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) ); if ( $questionId > 0 ) { $question = $this->questions->findById( $questionId ); if ( $question && $question->offeringId === (int) $offering->id ) { @@ -66,8 +67,8 @@ class QuestionController { private function addQuestion( int $offeringId ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing - $label = sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) ); - $fieldType = sanitize_key( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ); + $label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) ); + $fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { return; @@ -78,9 +79,9 @@ class QuestionController { offeringId: $offeringId, label: $label, fieldType: $fieldType, - options: $this->parseOptions( sanitize_textarea_field( wp_unslash( $_POST['options'] ?? '' ) ) ), + options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ), isRequired: isset( $_POST['is_required'] ), - sortOrder: absint( $_POST['sort_order'] ?? 0 ), + sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ), ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing diff --git a/src/Registration/QuestionEndpoint.php b/src/Registration/QuestionEndpoint.php index 78ce4db..92ebd97 100644 --- a/src/Registration/QuestionEndpoint.php +++ b/src/Registration/QuestionEndpoint.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Registration; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Val; class QuestionEndpoint { @@ -13,6 +14,11 @@ class QuestionEndpoint { private OfferingRepository $offerings, ) {} + /** + * Registers this endpoint's REST routes. + * + * @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`). + */ public function registerRoutes( string $route_namespace ): void { register_rest_route( $route_namespace, @@ -57,24 +63,24 @@ class QuestionEndpoint { } public function index( \WP_REST_Request $request ): \WP_REST_Response { - $questions = $this->questions->findByOffering( absint( $request->get_param( 'id' ) ), activeOnly: true ); + $questions = $this->questions->findByOffering( absint( Val::int( $request->get_param( 'id' ) ) ), activeOnly: true ); return new \WP_REST_Response( array_map( fn( Question $q ) => $q->toArray(), $questions ), 200 ); } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $offeringId = absint( $request->get_param( 'offering_id' ) ); + $offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $ownerCheck = $this->requireOfferingOwner( $offeringId ); if ( $ownerCheck instanceof \WP_Error ) { return $ownerCheck; } - $label = sanitize_text_field( (string) $request->get_param( 'label' ) ); + $label = sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ); if ( '' === $label ) { return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) ); } - $fieldType = (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 ) ) { return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); } @@ -85,7 +91,7 @@ class QuestionEndpoint { fieldType: $fieldType, options: $this->sanitizeOptions( $request->get_param( 'options' ) ), isRequired: (bool) $request->get_param( 'is_required' ), - sortOrder: (int) $request->get_param( 'sort_order' ), + sortOrder: Val::int( $request->get_param( 'sort_order' ) ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), ); @@ -95,7 +101,7 @@ class QuestionEndpoint { } public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $existing = $this->questions->findById( $id ); if ( null === $existing ) { @@ -107,18 +113,18 @@ class QuestionEndpoint { return $ownerCheck; } - $fieldType = $request->has_param( 'field_type' ) ? (string) $request->get_param( 'field_type' ) : $existing->fieldType; + $fieldType = $request->has_param( 'field_type' ) ? Val::string( $request->get_param( 'field_type' ) ) : $existing->fieldType; if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); } $question = new Question( offeringId: $existing->offeringId, - label: $request->has_param( 'label' ) ? sanitize_text_field( (string) $request->get_param( 'label' ) ) : $existing->label, + label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label, fieldType: $fieldType, 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, - sortOrder: $request->has_param( 'sort_order' ) ? (int) $request->get_param( 'sort_order' ) : $existing->sortOrder, + sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, id: $id, ); @@ -129,7 +135,7 @@ class QuestionEndpoint { } public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - $id = absint( $request->get_param( 'id' ) ); + $id = absint( Val::int( $request->get_param( 'id' ) ) ); $existing = $this->questions->findById( $id ); if ( null === $existing ) { @@ -192,7 +198,7 @@ class QuestionEndpoint { $options = array_values( array_filter( array_map( - static fn( $option ): string => sanitize_text_field( (string) $option ), + static fn( mixed $option ): string => sanitize_text_field( Val::string( $option ) ), $value ) ) diff --git a/src/Registration/QuestionRepository.php b/src/Registration/QuestionRepository.php index 09fc1a1..3d6f1f4 100644 --- a/src/Registration/QuestionRepository.php +++ b/src/Registration/QuestionRepository.php @@ -54,8 +54,8 @@ class QuestionRepository { * @return list */ public function findByOffering( int $offeringId, bool $activeOnly = false ): array { - $sql = "SELECT * FROM {$this->table} WHERE offering_id = %d"; - $params = [ $offeringId ]; + $sql = 'SELECT * FROM %i WHERE offering_id = %d'; + $params = [ $this->table, $offeringId ]; if ( $activeOnly ) { $sql .= ' AND is_active = %d'; @@ -71,7 +71,7 @@ class QuestionRepository { public function findById( int $id ): ?Question { $row = $this->db->get_row( - $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Question::fromRow( $row ) : null; diff --git a/src/Val.php b/src/Val.php new file mode 100644 index 0000000..c24f3f2 --- /dev/null +++ b/src/Val.php @@ -0,0 +1,60 @@ +db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/token = %s/'), 'tok123') + ->with(Mockery::pattern('/token = %s/'), 'wp_us_invites', 'tok123') ->andReturn('SELECT ...'); $this->db->shouldReceive('get_row')->andReturn($this->row()); @@ -71,7 +71,7 @@ class InviteRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/email = %s AND status = %s/'), 'a@b.test', Invite::STATUS_PENDING) + ->with(Mockery::pattern('/email = %s AND status = %s/'), 'wp_us_invites', 'a@b.test', Invite::STATUS_PENDING) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_row')->andReturn($this->row()); @@ -83,7 +83,7 @@ class InviteRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/status = %s/'), Invite::STATUS_PENDING) + ->with(Mockery::pattern('/status = %s/'), 'wp_us_invites', Invite::STATUS_PENDING) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([$this->row()]); diff --git a/tests/Unit/Availability/AvailabilityRepositoryTest.php b/tests/Unit/Availability/AvailabilityRepositoryTest.php index 896f001..7fe1274 100644 --- a/tests/Unit/Availability/AvailabilityRepositoryTest.php +++ b/tests/Unit/Availability/AvailabilityRepositoryTest.php @@ -147,11 +147,16 @@ class AvailabilityRepositoryTest extends TestCase self::assertFalse($this->repo->delete(1)); } - public function testFindAvailableWithNoFiltersUsesNoParams(): void + public function testFindAvailableWithNoFiltersPreparesTableOnly(): void { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/WHERE is_booked = 0/'), ['wp_us_availability']) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results') ->once() - ->with(Mockery::pattern('/WHERE is_booked = 0/')) + ->with('SELECT ...') ->andReturn([]); $result = $this->repo->findAvailable(); @@ -177,7 +182,7 @@ class AvailabilityRepositoryTest extends TestCase ->once() ->with( Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'), - Mockery::on(static fn (array $p): bool => $p === [8, 30]) + Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', 8, 30]) ) ->andReturn('SELECT ...'); diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 19dd4ca..2b0da4b 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -138,7 +138,7 @@ class BookingRepositoryTest extends TestCase $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00') + ->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00') ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->andReturn('3'); diff --git a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php index bae31c6..4450a57 100644 --- a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php +++ b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php @@ -48,7 +48,7 @@ class EnrollmentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 7, Enrollment::STATUS_ACTIVE) + ->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 'wp_us_group_enrollments', 7, Enrollment::STATUS_ACTIVE) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->andReturn('4'); @@ -60,7 +60,7 @@ class EnrollmentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/student_id = %d AND status = %s/'), 5, Enrollment::STATUS_ACTIVE) + ->with(Mockery::pattern('/student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 5, Enrollment::STATUS_ACTIVE) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->andReturn('2'); @@ -72,7 +72,7 @@ class EnrollmentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 7, 5, Enrollment::STATUS_ACTIVE) + ->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 7, 5, Enrollment::STATUS_ACTIVE) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->andReturn('1'); diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php index b0eb71f..d5c0aa8 100644 --- a/tests/Unit/Offering/OfferingRepositoryTest.php +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -109,11 +109,16 @@ class OfferingRepositoryTest extends TestCase self::assertSame(3, $offering->instructorId); } - public function testFindAllWithNoFiltersUsesNoParams(): void + public function testFindAllWithNoFiltersPreparesTableOnly(): void { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/WHERE 1 = 1/'), ['wp_us_offerings']) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results') ->once() - ->with(Mockery::pattern('/WHERE 1 = 1/')) + ->with('SELECT ...') ->andReturn([$this->sampleRow()]); $offerings = $this->repo->findAll(); @@ -126,7 +131,7 @@ class OfferingRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === [1])) + ->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 1])) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([]); @@ -140,7 +145,7 @@ class OfferingRepositoryTest extends TestCase ->once() ->with( Mockery::pattern('/instructor_id = %d AND kind = %s/'), - Mockery::on(static fn (array $p): bool => $p === [3, Offering::KIND_GROUP_CLASS]) + Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 3, Offering::KIND_GROUP_CLASS]) ) ->andReturn('SELECT ...'); diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php index 362a07e..f1ca515 100644 --- a/tests/Unit/Payment/PaymentRepositoryTest.php +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -66,7 +66,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 13.0, 13.0, 50) + ->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 'wp_us_payments', 13.0, 13.0, 50) ->andReturn('UPDATE ...'); $this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1); @@ -78,7 +78,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3]) + ->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['wp_us_payments', 'paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3]) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([$this->row()]); @@ -90,7 +90,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00']) + ->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['wp_us_payments', 'paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00']) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([]); @@ -118,7 +118,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'pi_123') + ->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'wp_us_payments', 'pi_123') ->andReturn('SELECT ...'); $this->db->shouldReceive('get_row')->andReturn($this->row()); @@ -138,7 +138,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), Payment::REG_LESSON, 12) + ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_payments', Payment::REG_LESSON, 12) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_row')->andReturn($this->row()); @@ -150,7 +150,7 @@ class PaymentRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/status = %s/'), Payment::STATUS_PENDING) + ->with(Mockery::pattern('/status = %s/'), 'wp_us_payments', Payment::STATUS_PENDING) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([$this->row()]); diff --git a/tests/Unit/Policy/AcceptanceRepositoryTest.php b/tests/Unit/Policy/AcceptanceRepositoryTest.php index 08dc1c0..d6d791a 100644 --- a/tests/Unit/Policy/AcceptanceRepositoryTest.php +++ b/tests/Unit/Policy/AcceptanceRepositoryTest.php @@ -70,7 +70,7 @@ class AcceptanceRepositoryTest extends TestCase { $this->db->shouldReceive('prepare') ->once() - ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), PolicyAcceptance::REG_LESSON, 12) + ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_policy_acceptances', PolicyAcceptance::REG_LESSON, 12) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->andReturn([ diff --git a/tests/Unit/Policy/PolicyRepositoryTest.php b/tests/Unit/Policy/PolicyRepositoryTest.php index 0ca8f70..37e2e3a 100644 --- a/tests/Unit/Policy/PolicyRepositoryTest.php +++ b/tests/Unit/Policy/PolicyRepositoryTest.php @@ -45,6 +45,7 @@ class PolicyRepositoryTest extends TestCase ->once() ->with( Mockery::pattern('/acceptance_scope = %s OR acceptance_scope = %s/'), + 'wp_us_policies', Policy::SCOPE_SIGNUP, Policy::SCOPE_BOTH ) diff --git a/tests/Unit/Registration/AnswerRepositoryTest.php b/tests/Unit/Registration/AnswerRepositoryTest.php index 2446b85..c00f021 100644 --- a/tests/Unit/Registration/AnswerRepositoryTest.php +++ b/tests/Unit/Registration/AnswerRepositoryTest.php @@ -84,6 +84,7 @@ class AnswerRepositoryTest extends TestCase ->once() ->with( Mockery::pattern('/registration_type = %s AND registration_id = %d/'), + 'wp_us_question_answers', Answer::REG_LESSON, 12 ) diff --git a/tests/Unit/Registration/QuestionRepositoryTest.php b/tests/Unit/Registration/QuestionRepositoryTest.php index b17fd60..17e1d9a 100644 --- a/tests/Unit/Registration/QuestionRepositoryTest.php +++ b/tests/Unit/Registration/QuestionRepositoryTest.php @@ -101,7 +101,7 @@ class QuestionRepositoryTest extends TestCase ->once() ->with( Mockery::pattern('/offering_id = %d AND is_active = %d/'), - Mockery::on(static fn (array $p): bool => $p === [7, 1]) + Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', 7, 1]) ) ->andReturn('SELECT ...'); diff --git a/tests/Unit/ValTest.php b/tests/Unit/ValTest.php new file mode 100644 index 0000000..94bd597 --- /dev/null +++ b/tests/Unit/ValTest.php @@ -0,0 +1,66 @@ +