From e61d99daedf75bf8a3b73c5bb9fe6c85afa5b474 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 5 Jun 2026 11:11:06 -0300 Subject: [PATCH] Add Registration Questions domain (per-offering intake forms) Implements #5: studio admin / instructors author intake questions scoped per offering; answers are stored against a lesson or group enrolment via a polymorphic registration reference. - src/Registration/: Question + Answer value objects, QuestionRepository and AnswerRepository, QuestionEndpoint (REST), QuestionController + templates/admin/questions.php (Offerings -> Questions submenu) - us_questions and us_question_answers tables in Schema.php - REST: public GET /offerings/{id}/questions; POST/PATCH/DELETE /questions gated by manage_questions + offering ownership (owner or studio admin) - Field types text/textarea/select/checkbox; select options stored as JSON - Wiring in Plugin, RestRegistrar, AdminMenu AnswerRepository is built now and consumed by the booking/enrolment flow in #3/#4. Tests: tests/Unit/Registration/ (19 tests). composer test (63 total), cs, and PHPStan level 6 all pass. Refs #5 Co-Authored-By: Claude Opus 4.8 --- src/AdminMenu.php | 16 +- src/Plugin.php | 6 +- src/Registration/Answer.php | 53 +++++ src/Registration/AnswerRepository.php | 57 +++++ src/Registration/Question.php | 77 +++++++ src/Registration/QuestionController.php | 111 ++++++++++ src/Registration/QuestionEndpoint.php | 198 ++++++++++++++++++ src/Registration/QuestionRepository.php | 87 ++++++++ src/RestRegistrar.php | 7 +- src/Schema.php | 29 +++ templates/admin/questions.php | 112 ++++++++++ .../Registration/AnswerRepositoryTest.php | 100 +++++++++ tests/Unit/Registration/AnswerTest.php | 57 +++++ .../Registration/QuestionRepositoryTest.php | 152 ++++++++++++++ tests/Unit/Registration/QuestionTest.php | 83 ++++++++ 15 files changed, 1141 insertions(+), 4 deletions(-) create mode 100644 src/Registration/Answer.php create mode 100644 src/Registration/AnswerRepository.php create mode 100644 src/Registration/Question.php create mode 100644 src/Registration/QuestionController.php create mode 100644 src/Registration/QuestionEndpoint.php create mode 100644 src/Registration/QuestionRepository.php create mode 100644 templates/admin/questions.php create mode 100644 tests/Unit/Registration/AnswerRepositoryTest.php create mode 100644 tests/Unit/Registration/AnswerTest.php create mode 100644 tests/Unit/Registration/QuestionRepositoryTest.php create mode 100644 tests/Unit/Registration/QuestionTest.php diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 956d531..0f7a7ca 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -10,17 +10,21 @@ use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Registration\QuestionController; +use Unsupervised\Schedular\Registration\QuestionRepository; class AdminMenu { private AvailabilityController $availabilityController; private LessonController $lessonController; private OfferingController $offeringController; + private QuestionController $questionController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions ) { $this->availabilityController = new AvailabilityController( $availability ); $this->lessonController = new LessonController( $bookings ); $this->offeringController = new OfferingController( $offerings ); + $this->questionController = new QuestionController( $questions, $offerings ); } public function register(): void { @@ -61,6 +65,16 @@ class AdminMenu { 33 ); + // Studio admin / instructor: manage per-offering intake questions. + add_submenu_page( + 'us-offerings', + __( 'Questions', 'unsupervised-schedular' ), + __( 'Questions', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_QUESTIONS, + 'us-questions', + [ $this->questionController, 'renderPage' ] + ); + // Instructor: view their upcoming lessons. add_menu_page( __( 'My Lessons', 'unsupervised-schedular' ), diff --git a/src/Plugin.php b/src/Plugin.php index 22ee832..0339d39 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Registration\QuestionRepository; class Plugin { @@ -17,10 +18,11 @@ class Plugin { $availability = new AvailabilityRepository( $wpdb ); $bookings = new BookingRepository( $wpdb ); $offerings = new OfferingRepository( $wpdb ); + $questions = new QuestionRepository( $wpdb ); ( new RoleManager() )->register(); - ( new AdminMenu( $availability, $bookings, $offerings ) )->register(); - ( new RestRegistrar( $availability, $bookings, $offerings ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions ) )->register(); ( new ShortcodeRegistrar() )->register(); } } diff --git a/src/Registration/Answer.php b/src/Registration/Answer.php new file mode 100644 index 0000000..27c3ff3 --- /dev/null +++ b/src/Registration/Answer.php @@ -0,0 +1,53 @@ + + */ + public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ]; + + public function __construct( + public readonly int $questionId, + public readonly string $registrationType, + public readonly int $registrationId, + public readonly int $studentId, + public readonly ?string $answerValue = null, + public readonly ?int $id = null, + ) {} + + public static function fromRow( object $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, + ); + } + + /** + * Returns a plain array representation of the answer. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'question_id' => $this->questionId, + 'registration_type' => $this->registrationType, + 'registration_id' => $this->registrationId, + 'student_id' => $this->studentId, + 'answer_value' => $this->answerValue, + ]; + } +} diff --git a/src/Registration/AnswerRepository.php b/src/Registration/AnswerRepository.php new file mode 100644 index 0000000..77a157f --- /dev/null +++ b/src/Registration/AnswerRepository.php @@ -0,0 +1,57 @@ +table = $db->prefix . 'us_question_answers'; + } + + public function insert( Answer $answer ): int { + $this->db->insert( + $this->table, + [ + 'question_id' => $answer->questionId, + 'registration_type' => $answer->registrationType, + 'registration_id' => $answer->registrationId, + 'student_id' => $answer->studentId, + 'answer_value' => $answer->answerValue, + 'created_at' => current_time( 'mysql' ), + ], + [ '%d', '%s', '%d', '%d', '%s', '%s' ] + ); + + return $this->db->insert_id; + } + + /** + * Persist a batch of answers for a single registration. + * + * @param list $answers + * @return list Inserted answer IDs. + */ + public function insertMany( array $answers ): array { + return array_map( fn( Answer $answer ): int => $this->insert( $answer ), $answers ); + } + + /** + * Find all answers attached to a registration (lesson or enrolment). + * + * @return list + */ + 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", + $registrationType, + $registrationId + ) + ); + + return array_map( Answer::fromRow( ... ), $rows ?? [] ); + } +} diff --git a/src/Registration/Question.php b/src/Registration/Question.php new file mode 100644 index 0000000..055dae4 --- /dev/null +++ b/src/Registration/Question.php @@ -0,0 +1,77 @@ + + */ + public const VALID_FIELD_TYPES = [ + self::FIELD_TEXT, + self::FIELD_TEXTAREA, + self::FIELD_SELECT, + self::FIELD_CHECKBOX, + ]; + + /** + * Build an intake question value object. + * + * @param list|null $options Choices for a `select` field. + */ + public function __construct( + public readonly int $offeringId, + public readonly string $label, + public readonly string $fieldType = self::FIELD_TEXT, + public readonly ?array $options = null, + public readonly bool $isRequired = false, + public readonly int $sortOrder = 0, + public readonly bool $isActive = true, + public readonly ?int $id = null, + ) {} + + public static function fromRow( object $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; + } + + return new self( + offeringId: (int) $row->offering_id, + label: $row->label, + fieldType: $row->field_type, + options: $options, + isRequired: (bool) $row->is_required, + sortOrder: (int) $row->sort_order, + isActive: (bool) $row->is_active, + id: (int) $row->id, + ); + } + + /** + * Returns a plain array representation of the question. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'offering_id' => $this->offeringId, + 'label' => $this->label, + 'field_type' => $this->fieldType, + 'options' => $this->options, + 'is_required' => $this->isRequired, + 'sort_order' => $this->sortOrder, + 'is_active' => $this->isActive, + ]; + } +} diff --git a/src/Registration/QuestionController.php b/src/Registration/QuestionController.php new file mode 100644 index 0000000..6dfeb3b --- /dev/null +++ b/src/Registration/QuestionController.php @@ -0,0 +1,111 @@ +offerings->findAll() : $this->offerings->findAll( $userId ); + $selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; + + if ( null !== $selectedOffering && ! $this->canManageOffering( $selectedOffering, $userId, $manageAll ) ) { + $selectedOffering = null; + } + + $questions = null; + if ( null !== $selectedOffering ) { + if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) { + $this->handleFormAction( $selectedOffering ); + } + + $questions = $this->questions->findByOffering( (int) $selectedOffering->id ); + } + + include USC_PLUGIN_DIR . 'templates/admin/questions.php'; + } + + 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'] ?? '' ) ); + + if ( 'add' === $action ) { + $this->addQuestion( (int) $offering->id ); + } + + if ( 'delete' === $action ) { + $questionId = absint( $_POST['question_id'] ?? 0 ); + if ( $questionId > 0 ) { + $question = $this->questions->findById( $questionId ); + if ( $question && $question->offeringId === (int) $offering->id ) { + $this->questions->delete( $questionId ); + } + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + 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 ) ); + + if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { + return; + } + + $this->questions->insert( + new Question( + offeringId: $offeringId, + label: $label, + fieldType: $fieldType, + options: $this->parseOptions( sanitize_textarea_field( wp_unslash( $_POST['options'] ?? '' ) ) ), + isRequired: isset( $_POST['is_required'] ), + sortOrder: absint( $_POST['sort_order'] ?? 0 ), + ) + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool { + return $manageAll || $offering->instructorId === $userId; + } + + /** + * Parse a newline-separated textarea into a list of option strings. + * + * @return list|null + */ + private function parseOptions( string $raw ): ?array { + $lines = preg_split( '/\r\n|\r|\n/', $raw ); + $options = array_values( + array_filter( + array_map( + static fn( string $line ): string => sanitize_text_field( trim( $line ) ), + false === $lines ? [] : $lines + ) + ) + ); + + return [] === $options ? null : $options; + } +} diff --git a/src/Registration/QuestionEndpoint.php b/src/Registration/QuestionEndpoint.php new file mode 100644 index 0000000..35fcad5 --- /dev/null +++ b/src/Registration/QuestionEndpoint.php @@ -0,0 +1,198 @@ +\d+)/questions', + [ + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ $this, 'index' ], + 'permission_callback' => '__return_true', + ], + ] + ); + + register_rest_route( + $route_namespace, + '/questions', + [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + ] + ); + + register_rest_route( + $route_namespace, + '/questions/(?P\d+)', + [ + [ + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'update' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'delete' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + ] + ); + } + + public function index( \WP_REST_Request $request ): \WP_REST_Response { + $questions = $this->questions->findByOffering( absint( $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' ) ); + $ownerCheck = $this->requireOfferingOwner( $offeringId ); + if ( $ownerCheck instanceof \WP_Error ) { + return $ownerCheck; + } + + $label = sanitize_text_field( (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 ); + if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { + return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); + } + + $question = new Question( + offeringId: $offeringId, + label: $label, + fieldType: $fieldType, + options: $this->sanitizeOptions( $request->get_param( 'options' ) ), + isRequired: (bool) $request->get_param( 'is_required' ), + sortOrder: (int) $request->get_param( 'sort_order' ), + isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), + ); + + $id = $this->questions->insert( $question ); + + return new \WP_REST_Response( [ 'id' => $id ], 201 ); + } + + public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $id = absint( $request->get_param( 'id' ) ); + $existing = $this->questions->findById( $id ); + + if ( null === $existing ) { + return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + + $ownerCheck = $this->requireOfferingOwner( $existing->offeringId ); + if ( $ownerCheck instanceof \WP_Error ) { + return $ownerCheck; + } + + $fieldType = $request->has_param( 'field_type' ) ? (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, + 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, + isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, + id: $id, + ); + + $this->questions->update( $id, $question ); + + return new \WP_REST_Response( $question->toArray(), 200 ); + } + + public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $id = absint( $request->get_param( 'id' ) ); + $existing = $this->questions->findById( $id ); + + if ( null === $existing ) { + return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + + $ownerCheck = $this->requireOfferingOwner( $existing->offeringId ); + if ( $ownerCheck instanceof \WP_Error ) { + return $ownerCheck; + } + + $this->questions->delete( $id ); + + return new \WP_REST_Response( null, 204 ); + } + + public function canManage(): bool { + return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_QUESTIONS ); + } + + /** + * Ensure the offering exists and the caller owns it (or is a studio admin). + */ + private function requireOfferingOwner( int $offeringId ): ?\WP_Error { + $offering = $this->offerings->findById( $offeringId ); + + if ( null === $offering ) { + return new \WP_Error( 'not_found', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + + $ownsOrManagesAll = get_current_user_id() === $offering->instructorId + || current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); + + if ( ! $ownsOrManagesAll ) { + return new \WP_Error( 'forbidden', __( 'You cannot manage questions for this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } + + return null; + } + + /** + * Normalise a submitted options array into a clean list of strings. + * + * @return list|null + */ + private function sanitizeOptions( mixed $value ): ?array { + if ( ! is_array( $value ) || [] === $value ) { + return null; + } + + $options = array_values( + array_filter( + array_map( + static fn( $option ): string => sanitize_text_field( (string) $option ), + $value + ) + ) + ); + + return [] === $options ? null : $options; + } + + private function invalid( string $message ): \WP_Error { + return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] ); + } +} diff --git a/src/Registration/QuestionRepository.php b/src/Registration/QuestionRepository.php new file mode 100644 index 0000000..09fc1a1 --- /dev/null +++ b/src/Registration/QuestionRepository.php @@ -0,0 +1,87 @@ +table = $db->prefix . 'us_questions'; + } + + public function insert( Question $question ): int { + $this->db->insert( + $this->table, + $this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ], + [ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ] + ); + + return $this->db->insert_id; + } + + public function update( int $id, Question $question ): bool { + return false !== $this->db->update( + $this->table, + $this->columns( $question ), + [ 'id' => $id ], + [ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ], + [ '%d' ] + ); + } + + /** + * Column values shared by insert and update (excludes created_at). + * + * @return array + */ + private function columns( Question $question ): array { + return [ + 'offering_id' => $question->offeringId, + 'label' => $question->label, + 'field_type' => $question->fieldType, + 'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ), + 'is_required' => $question->isRequired ? 1 : 0, + 'sort_order' => $question->sortOrder, + 'is_active' => $question->isActive ? 1 : 0, + ]; + } + + /** + * Find questions for an offering, ordered for display. + * + * @return list + */ + public function findByOffering( int $offeringId, bool $activeOnly = false ): array { + $sql = "SELECT * FROM {$this->table} WHERE offering_id = %d"; + $params = [ $offeringId ]; + + if ( $activeOnly ) { + $sql .= ' AND is_active = %d'; + $params[] = 1; + } + + $sql .= ' ORDER BY sort_order ASC, id ASC'; + + $rows = $this->db->get_results( $this->db->prepare( $sql, $params ) ); + + return array_map( Question::fromRow( ... ), $rows ?? [] ); + } + + public function findById( int $id ): ?Question { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + ); + + return $row ? Question::fromRow( $row ) : null; + } + + public function delete( int $id ): bool { + return (bool) $this->db->delete( + $this->table, + [ 'id' => $id ], + [ '%d' ] + ); + } +} diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 59ccf34..0e9fd4d 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -9,6 +9,8 @@ use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Registration\QuestionEndpoint; +use Unsupervised\Schedular\Registration\QuestionRepository; class RestRegistrar { @@ -17,11 +19,13 @@ class RestRegistrar { private AvailabilityEndpoint $availabilityEndpoint; private BookingEndpoint $bookingEndpoint; private OfferingEndpoint $offeringEndpoint; + private QuestionEndpoint $questionEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings ); $this->offeringEndpoint = new OfferingEndpoint( $offerings ); + $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); } public function register(): void { @@ -32,5 +36,6 @@ class RestRegistrar { $this->availabilityEndpoint->registerRoutes( self::NAMESPACE ); $this->bookingEndpoint->registerRoutes( self::NAMESPACE ); $this->offeringEndpoint->registerRoutes( self::NAMESPACE ); + $this->questionEndpoint->registerRoutes( self::NAMESPACE ); } } diff --git a/src/Schema.php b/src/Schema.php index 0ea26d1..3675b90 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -60,6 +60,35 @@ class Schema { KEY kind (kind), KEY is_active (is_active) ) {$charset};", + + "CREATE TABLE {$prefix}us_questions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + offering_id BIGINT UNSIGNED NOT NULL, + label VARCHAR(255) NOT NULL, + field_type VARCHAR(20) NOT NULL DEFAULT 'text', + options TEXT, + is_required TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY offering_id (offering_id), + KEY is_active (is_active) + ) {$charset};", + + "CREATE TABLE {$prefix}us_question_answers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + question_id BIGINT UNSIGNED NOT NULL, + registration_type VARCHAR(20) NOT NULL, + registration_id BIGINT UNSIGNED NOT NULL, + student_id BIGINT UNSIGNED NOT NULL, + answer_value TEXT, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY question_id (question_id), + KEY registration (registration_type, registration_id), + KEY student_id (student_id) + ) {$charset};", ]; } } diff --git a/templates/admin/questions.php b/templates/admin/questions.php new file mode 100644 index 0000000..c9bae6d --- /dev/null +++ b/templates/admin/questions.php @@ -0,0 +1,112 @@ + $offeringList + * @var \Unsupervised\Schedular\Offering\Offering|null $selectedOffering + * @var list<\Unsupervised\Schedular\Registration\Question>|null $questions + */ +?> +
+

+ +
+ + + +
+ + +

+ +

title)); ?>

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + +
sortOrder); ?>label); ?>fieldType); ?>isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?> +
+ + + + +
+
+ + +
diff --git a/tests/Unit/Registration/AnswerRepositoryTest.php b/tests/Unit/Registration/AnswerRepositoryTest.php new file mode 100644 index 0000000..2446b85 --- /dev/null +++ b/tests/Unit/Registration/AnswerRepositoryTest.php @@ -0,0 +1,100 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new AnswerRepository($this->db); + } + + public function testInsertCallsWpdbInsertAndReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_question_answers', + Mockery::on(static function (array $data): bool { + return $data['question_id'] === 3 + && $data['registration_type'] === Answer::REG_LESSON + && $data['registration_id'] === 12 + && $data['student_id'] === 5 + && $data['answer_value'] === 'Beginner'; + }), + ['%d', '%s', '%d', '%d', '%s', '%s'] + ); + + $this->db->insert_id = 77; + + $answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner'); + + self::assertSame(77, $this->repo->insert($answer)); + } + + public function testInsertManyReturnsAllIds(): void + { + Functions\when('current_time')->justReturn('2026-04-01 12:00:00'); + + $ids = [101, 102]; + $this->db->shouldReceive('insert') + ->twice() + ->andReturnUsing(function () use (&$ids): void { + $this->db->insert_id = array_shift($ids); + }); + + $answers = [ + new Answer(3, Answer::REG_LESSON, 12, 5, 'A'), + new Answer(4, Answer::REG_LESSON, 12, 5, 'B'), + ]; + + $result = $this->repo->insertMany($answers); + + self::assertSame([101, 102], $result); + } + + public function testFindByRegistrationPreparesQueryAndMaps(): void + { + $row = (object) [ + 'id' => '1', + 'question_id' => '3', + 'registration_type' => Answer::REG_LESSON, + 'registration_id' => '12', + 'student_id' => '5', + 'answer_value' => 'Beginner', + ]; + + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/registration_type = %s AND registration_id = %d/'), + Answer::REG_LESSON, + 12 + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([$row]); + + $answers = $this->repo->findByRegistration(Answer::REG_LESSON, 12); + + self::assertCount(1, $answers); + self::assertInstanceOf(Answer::class, $answers[0]); + self::assertSame(3, $answers[0]->questionId); + } +} diff --git a/tests/Unit/Registration/AnswerTest.php b/tests/Unit/Registration/AnswerTest.php new file mode 100644 index 0000000..e3ba439 --- /dev/null +++ b/tests/Unit/Registration/AnswerTest.php @@ -0,0 +1,57 @@ +questionId); + self::assertSame(Answer::REG_LESSON, $answer->registrationType); + self::assertSame(12, $answer->registrationId); + self::assertSame(5, $answer->studentId); + self::assertSame('Beginner', $answer->answerValue); + self::assertSame(99, $answer->id); + } + + public function testFromRowMapsCorrectly(): void + { + $row = (object) [ + 'id' => '99', + 'question_id' => '3', + 'registration_type' => Answer::REG_ENROLLMENT, + 'registration_id' => '12', + 'student_id' => '5', + 'answer_value' => '1', + ]; + + $answer = Answer::fromRow($row); + + self::assertSame(99, $answer->id); + self::assertSame(3, $answer->questionId); + self::assertSame(Answer::REG_ENROLLMENT, $answer->registrationType); + self::assertSame(12, $answer->registrationId); + } + + public function testToArrayContainsExpectedKeys(): void + { + $answer = new Answer(3, Answer::REG_LESSON, 12, 5); + $arr = $answer->toArray(); + + foreach (['id', 'question_id', 'registration_type', 'registration_id', 'student_id', 'answer_value'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } + + public function testValidRegistrationTypeConstants(): void + { + self::assertContains(Answer::REG_LESSON, Answer::VALID_REGISTRATION_TYPES); + self::assertContains(Answer::REG_ENROLLMENT, Answer::VALID_REGISTRATION_TYPES); + } +} diff --git a/tests/Unit/Registration/QuestionRepositoryTest.php b/tests/Unit/Registration/QuestionRepositoryTest.php new file mode 100644 index 0000000..b17fd60 --- /dev/null +++ b/tests/Unit/Registration/QuestionRepositoryTest.php @@ -0,0 +1,152 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new QuestionRepository($this->db); + } + + public function testInsertWithNullOptionsStoresNull(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_questions', + Mockery::on(static function (array $data): bool { + return $data['offering_id'] === 7 + && $data['label'] === 'Your level?' + && $data['field_type'] === Question::FIELD_TEXT + && $data['options'] === null + && $data['is_required'] === 0 + && $data['created_at'] === '2026-04-01 12:00:00'; + }), + Mockery::type('array') + ); + + $this->db->insert_id = 21; + + $question = new Question(7, 'Your level?'); + + self::assertSame(21, $this->repo->insert($question)); + } + + public function testInsertEncodesOptionsAsJson(): void + { + Functions\expect('current_time')->andReturn('2026-04-01 12:00:00'); + Functions\expect('wp_json_encode') + ->once() + ->with(['Beginner', 'Advanced']) + ->andReturn('["Beginner","Advanced"]'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_questions', + Mockery::on(static fn (array $data): bool => $data['options'] === '["Beginner","Advanced"]'), + Mockery::type('array') + ); + + $this->db->insert_id = 22; + + $question = new Question( + offeringId: 7, + label: 'Pick a level', + fieldType: Question::FIELD_SELECT, + options: ['Beginner', 'Advanced'], + ); + + self::assertSame(22, $this->repo->insert($question)); + } + + public function testUpdateReturnsTrueOnSuccess(): void + { + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_questions', + Mockery::on(static fn (array $data): bool => $data['label'] === 'Renamed' && $data['is_required'] === 1), + ['id' => 5], + Mockery::type('array'), + ['%d'] + ) + ->andReturn(1); + + $question = new Question(7, 'Renamed', isRequired: true, id: 5); + + self::assertTrue($this->repo->update(5, $question)); + } + + public function testFindByOfferingActiveOnlyPreparesQuery(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/offering_id = %d AND is_active = %d/'), + Mockery::on(static fn (array $p): bool => $p === [7, 1]) + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + self::assertSame([], $this->repo->findByOffering(7, activeOnly: true)); + } + + public function testFindByOfferingReturnsQuestions(): void + { + $row = (object) [ + 'id' => '3', + 'offering_id' => '7', + 'label' => 'Q', + 'field_type' => Question::FIELD_TEXT, + 'options' => null, + 'is_required' => '0', + 'sort_order' => '0', + 'is_active' => '1', + ]; + + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results')->andReturn([$row]); + + $questions = $this->repo->findByOffering(7); + + self::assertCount(1, $questions); + self::assertInstanceOf(Question::class, $questions[0]); + } + + public function testFindByIdReturnsNullWhenNotFound(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_row')->andReturn(null); + + self::assertNull($this->repo->findById(99)); + } + + public function testDeleteCallsWpdbDelete(): void + { + $this->db->shouldReceive('delete') + ->once() + ->with('wp_us_questions', ['id' => 4], ['%d']) + ->andReturn(1); + + self::assertTrue($this->repo->delete(4)); + } +} diff --git a/tests/Unit/Registration/QuestionTest.php b/tests/Unit/Registration/QuestionTest.php new file mode 100644 index 0000000..4fbb66b --- /dev/null +++ b/tests/Unit/Registration/QuestionTest.php @@ -0,0 +1,83 @@ +offeringId); + self::assertSame('Your level?', $question->label); + self::assertSame(Question::FIELD_TEXT, $question->fieldType); + self::assertNull($question->options); + self::assertFalse($question->isRequired); + self::assertSame(0, $question->sortOrder); + self::assertTrue($question->isActive); + self::assertNull($question->id); + } + + public function testFromRowDecodesOptionsJson(): void + { + $row = (object) [ + 'id' => '3', + 'offering_id' => '7', + 'label' => 'Pick a level', + 'field_type' => Question::FIELD_SELECT, + 'options' => '["Beginner","Advanced"]', + 'is_required' => '1', + 'sort_order' => '2', + 'is_active' => '1', + ]; + + $question = Question::fromRow($row); + + self::assertSame(3, $question->id); + self::assertSame(Question::FIELD_SELECT, $question->fieldType); + self::assertSame(['Beginner', 'Advanced'], $question->options); + self::assertTrue($question->isRequired); + self::assertSame(2, $question->sortOrder); + } + + public function testFromRowHandlesNullOptions(): void + { + $row = (object) [ + 'id' => '4', + 'offering_id' => '7', + 'label' => 'Notes', + 'field_type' => Question::FIELD_TEXTAREA, + 'options' => null, + 'is_required' => '0', + 'sort_order' => '0', + 'is_active' => '0', + ]; + + $question = Question::fromRow($row); + + self::assertNull($question->options); + self::assertFalse($question->isActive); + } + + public function testToArrayContainsExpectedKeys(): void + { + $question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9); + $arr = $question->toArray(); + + foreach (['id', 'offering_id', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } + + public function testValidFieldTypeConstants(): void + { + self::assertContains(Question::FIELD_TEXT, Question::VALID_FIELD_TYPES); + self::assertContains(Question::FIELD_TEXTAREA, Question::VALID_FIELD_TYPES); + self::assertContains(Question::FIELD_SELECT, Question::VALID_FIELD_TYPES); + self::assertContains(Question::FIELD_CHECKBOX, Question::VALID_FIELD_TYPES); + } +} -- 2.52.0