diff --git a/includes/class-sensei-cli.php b/includes/class-sensei-cli.php index 4b5667d8d7..20f0c0a964 100644 --- a/includes/class-sensei-cli.php +++ b/includes/class-sensei-cli.php @@ -5,7 +5,9 @@ * @package sensei */ -defined( 'ABSPATH' ) || exit; +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} /** * CLI class. @@ -17,21 +19,14 @@ class Sensei_CLI { * Class constructor. */ public function __construct() { - $this->load(); $this->register(); } - /** - * Load the command files. - */ - private function load() { - require_once dirname( __FILE__ ) . '/cli/class-sensei-db-seed-command.php'; - } - /** * Register the CLI commands. */ private function register() { WP_CLI::add_command( 'sensei db seed', Sensei_DB_Seed_Command::class ); + WP_CLI::add_command( 'sensei validate progress', Sensei_Validate_Progress_Command::class ); } } diff --git a/includes/class-sensei-course.php b/includes/class-sensei-course.php index 5b8d59734c..3f5e168cd9 100755 --- a/includes/class-sensei-course.php +++ b/includes/class-sensei-course.php @@ -2302,9 +2302,7 @@ public function load_user_courses_content( $user = false ) { * Returns a list of all courses * * @since 1.8.0 - * @return array $courses{ - * @type $course WP_Post - * } + * @return WP_Post[] */ public static function get_all_courses() { diff --git a/includes/cli/class-sensei-db-seed-command.php b/includes/cli/class-sensei-db-seed-command.php index 1a01b1c14d..a2df020387 100644 --- a/includes/cli/class-sensei-db-seed-command.php +++ b/includes/cli/class-sensei-db-seed-command.php @@ -5,7 +5,9 @@ * @package sensei */ -defined( 'ABSPATH' ) || exit; +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} /** * WP-CLI command that helps with seeding the database. diff --git a/includes/cli/class-sensei-validate-progress-command.php b/includes/cli/class-sensei-validate-progress-command.php new file mode 100644 index 0000000000..26c5c94207 --- /dev/null +++ b/includes/cli/class-sensei-validate-progress-command.php @@ -0,0 +1,66 @@ +run(); + + if ( ! $progress_validation->has_errors() ) { + WP_CLI::success( 'Progress data is valid.' ); + return; + } + + $this->output_validation_errors( $progress_validation ); + + WP_CLI::error( 'Progress data is not valid.' ); + } + + /** + * Output the validation errors. + * + * @since $$next-version$$ + * + * @param Progress_Validation $progress_validation Progress validation. + */ + private function output_validation_errors( Progress_Validation $progress_validation ) { + foreach ( $progress_validation->get_errors() as $error ) { + WP_CLI::warning( $error->get_message() ); + + if ( $error->has_data() ) { + $error_data = $error->get_data(); + $error_data = is_array( $error_data[0] ) ? $error_data : [ $error_data ]; + + WP_CLI\Utils\format_items( + 'table', + $error_data, + array_keys( $error_data[0] ) + ); + } + } + } +} diff --git a/includes/internal/migration/migrations/class-student-progress-migration.php b/includes/internal/migration/migrations/class-student-progress-migration.php index b038654cde..7af3ee4b8d 100644 --- a/includes/internal/migration/migrations/class-student-progress-migration.php +++ b/includes/internal/migration/migrations/class-student-progress-migration.php @@ -122,7 +122,7 @@ private function get_comments_and_meta( int $after_comment_id, bool $dry_run ): // At the moment we don't care about post meta for course progress. if ( 'sensei_lesson_status' === $progress_comment->comment_type ) { // Map the post ID to the comment ID. Is used later to map post meta to the comment ID. - $post_ids[ $progress_comment->comment_post_ID ] = $progress_comment->comment_ID; + $post_ids[ $progress_comment->comment_post_ID ][] = $progress_comment->comment_ID; } } @@ -188,8 +188,10 @@ private function get_comments_and_meta( int $after_comment_id, bool $dry_run ): $mapped_meta[ $comment_id ]['status'] = $comment_status; } } else { - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - $mapped_meta[ $comment_id ][ $meta->meta_key ] = $meta->meta_value; + foreach ( $post_ids[ $meta->post_id ] as $comment_id ) { + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + $mapped_meta[ $comment_id ][ $meta->meta_key ] = $meta->meta_value; + } } } diff --git a/includes/internal/migration/validations/class-progress-validation.php b/includes/internal/migration/validations/class-progress-validation.php new file mode 100644 index 0000000000..1510ac7695 --- /dev/null +++ b/includes/internal/migration/validations/class-progress-validation.php @@ -0,0 +1,392 @@ +errors = array(); + + if ( ! $this->is_progress_migration_complete() ) { + $this->add_error( 'The progress migration is not complete. Please run the progress migration first.' ); + } + + foreach ( $this->get_course_ids() as $course_id ) { + $this->validate_course_progress( $course_id ); + } + + foreach ( $this->get_lesson_ids() as $lesson_id ) { + $this->validate_lesson_progress( $lesson_id ); + } + + foreach ( $this->get_quiz_ids() as $quiz_id ) { + $this->validate_quiz_progress( $quiz_id ); + } + } + + /** + * Check if there are validation errors. + * + * @internal + * + * @since $$next-version$$ + * + * @return bool + */ + public function has_errors(): bool { + return (bool) $this->errors; + } + + /** + * Get the validation errors. + * + * @internal + * + * @since $$next-version$$ + * + * @return Validation_Error[] + */ + public function get_errors(): array { + return $this->errors; + } + + /** + * Add a validation error. + * + * @param string $message Error message. + * @param array $data Error data. + */ + private function add_error( string $message, array $data = [] ): void { + $this->errors[] = new Validation_Error( $message, $data ); + } + + /** + * Get the course IDs. + * + * @psalm-suppress InvalidReturnType, InvalidReturnStatement -- Psalm doesn't understand the 'fields' argument. + * + * @return int[] + */ + private function get_course_ids(): array { + return get_posts( + [ + 'post_type' => 'course', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] + ); + } + + /** + * Get the lesson IDs. + * + * @psalm-suppress InvalidReturnType, InvalidReturnStatement -- Psalm doesn't understand the 'fields' argument. + * + * @return int[] + */ + private function get_lesson_ids(): array { + return get_posts( + [ + 'post_type' => 'lesson', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] + ); + } + + /** + * Get the quiz IDs. + * + * @psalm-suppress InvalidReturnType, InvalidReturnStatement -- Psalm doesn't understand the 'fields' argument. + * + * @return int[] + */ + private function get_quiz_ids(): array { + return get_posts( + [ + 'post_type' => 'quiz', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] + ); + } + + /** + * Check if the progress migration is complete. + * + * @return bool + */ + private function is_progress_migration_complete(): bool { + return (bool) get_option( Migration_Job_Scheduler::COMPLETED_OPTION_NAME, false ); + } + + /** + * Validate the course progress. + * + * @param int $course_id Course post ID. + */ + private function validate_course_progress( int $course_id ): void { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->comments} + WHERE comment_type = 'sensei_course_status' + AND comment_post_ID = %d", + $course_id + ) + ); + if ( ! $user_ids ) { + return; + } + + $comments_based_repository = new Comments_Based_Course_Progress_Repository(); + $tables_based_repository = new Tables_Based_Course_Progress_Repository( $wpdb ); + + foreach ( $user_ids as $user_id ) { + $comments_based_progress = $comments_based_repository->get( $course_id, $user_id ); + $tables_based_progress = $tables_based_repository->get( $course_id, $user_id ); + + if ( ! $comments_based_progress ) { + $this->add_error( + 'Course comments based progress not found.', + [ + 'course_id' => $course_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + if ( ! $tables_based_progress ) { + $this->add_error( + 'Course tables based progress not found.', + [ + 'course_id' => $course_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + $this->compare_progress( $comments_based_progress, $tables_based_progress ); + } + } + + /** + * Validate the lesson progress. + * + * @param int $lesson_id Lesson post ID. + */ + private function validate_lesson_progress( int $lesson_id ): void { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->comments} + WHERE comment_type = 'sensei_lesson_status' + AND comment_post_ID = %d", + $lesson_id + ) + ); + if ( ! $user_ids ) { + return; + } + + $comments_based_repository = new Comments_Based_Lesson_Progress_Repository(); + $tables_based_repository = new Tables_Based_Lesson_Progress_Repository( $wpdb ); + + foreach ( $user_ids as $user_id ) { + $comments_based_progress = $comments_based_repository->get( $lesson_id, $user_id ); + $tables_based_progress = $tables_based_repository->get( $lesson_id, $user_id ); + + if ( ! $comments_based_progress ) { + $this->add_error( + 'Lesson comments based progress not found.', + [ + 'lesson_id' => $lesson_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + if ( ! $tables_based_progress ) { + $this->add_error( + 'Lesson tables based progress not found.', + [ + 'lesson_id' => $lesson_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + $this->compare_progress( $comments_based_progress, $tables_based_progress ); + } + } + + /** + * Validate the quiz progress. + * + * @param int $quiz_id Quiz post ID. + */ + private function validate_quiz_progress( int $quiz_id ): void { + $lesson_id = Sensei()->quiz->get_lesson_id( $quiz_id ); + + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->comments} + WHERE comment_type = 'sensei_lesson_status' + AND comment_post_ID = %d", + $lesson_id + ) + ); + if ( ! $user_ids ) { + return; + } + + $comments_based_repository = new Comments_Based_Quiz_Progress_Repository(); + $tables_based_repository = new Tables_Based_Quiz_Progress_Repository( $wpdb ); + + foreach ( $user_ids as $user_id ) { + $comments_based_progress = $comments_based_repository->get( $quiz_id, $user_id ); + $tables_based_progress = $tables_based_repository->get( $quiz_id, $user_id ); + + if ( ! $comments_based_progress ) { + $this->add_error( + 'Quiz comments based progress not found.', + [ + 'quiz_id' => $quiz_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + if ( ! $tables_based_progress ) { + $this->add_error( + 'Quiz tables based progress not found.', + [ + 'quiz_id' => $quiz_id, + 'user_id' => $user_id, + ] + ); + continue; + } + + $this->compare_progress( $comments_based_progress, $tables_based_progress ); + } + } + + /** + * Compare the comments and tables based progress. + * + * @param object $comments_based_progress Comments based progress. + * @param object $tables_based_progress Tables based progress. + */ + private function compare_progress( object $comments_based_progress, object $tables_based_progress ): void { + // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- Intended. + if ( $this->get_progress_data( $comments_based_progress ) != $this->get_progress_data( $tables_based_progress ) ) { + $this->add_mismatch_error( $comments_based_progress, $tables_based_progress ); + } + } + + /** + * Get the progress data. + * + * @param object $progress Progress. + * + * @return array + * @throws \InvalidArgumentException When invalid progress type is provided. + */ + private function get_progress_data( object $progress ): array { + if ( $progress instanceof Course_Progress_Interface ) { + $type = 'course'; + $post_id = $progress->get_course_id(); + } elseif ( $progress instanceof Lesson_Progress_Interface ) { + $type = 'lesson'; + $post_id = $progress->get_lesson_id(); + } elseif ( $progress instanceof Quiz_Progress_Interface ) { + $type = 'quiz'; + $post_id = $progress->get_quiz_id(); + } else { + throw new \InvalidArgumentException( 'Invalid progress type.' ); + } + + return [ + 'type' => $type, + 'post_id' => $post_id, + 'user_id' => $progress->get_user_id(), + 'status' => $progress->get_status(), + 'started_at' => $progress->get_started_at(), + 'completed_at' => $progress->get_completed_at(), + ]; + } + + /** + * Log a progress mismatch. + * + * @param object $comments_based_progress Comments based progress. + * @param object $tables_based_progress Tables based progress. + */ + private function add_mismatch_error( object $comments_based_progress, object $tables_based_progress ): void { + $this->add_error( + 'Data mismatch between comments and tables based progress.', + [ + array_merge( + [ + 'source' => 'comments', + ], + $this->get_progress_data( $comments_based_progress ) + ), + array_merge( + [ + 'source' => 'tables', + ], + $this->get_progress_data( $tables_based_progress ) + ), + ] + ); + } +} diff --git a/includes/internal/migration/validations/class-validation-error.php b/includes/internal/migration/validations/class-validation-error.php new file mode 100644 index 0000000000..e8021d85cc --- /dev/null +++ b/includes/internal/migration/validations/class-validation-error.php @@ -0,0 +1,86 @@ +message = $message; + $this->data = $data; + } + + /** + * Get the error message. + * + * @internal + * + * @since $$next-version$$ + * + * @return string + */ + public function get_message(): string { + return $this->message; + } + + /** + * Get the error data. + * + * @internal + * + * @since $$next-version$$ + * + * @return array + */ + public function get_data(): array { + return $this->data; + } + + /** + * Check if there is error data. + * + * @internal + * + * @since $$next-version$$ + * + * @return bool + */ + public function has_data(): bool { + return (bool) $this->data; + } +} diff --git a/psalm.xml b/psalm.xml index 43611cc0a8..9288bddc11 100644 --- a/psalm.xml +++ b/psalm.xml @@ -25,6 +25,7 @@ + @@ -32,6 +33,20 @@ - + + + + + + + + + + + + + + + diff --git a/tests/unit-tests/internal/migration/migrations/test-class-student-progress-migration.php b/tests/unit-tests/internal/migration/migrations/test-class-student-progress-migration.php index 821b153bd4..60136e86d8 100644 --- a/tests/unit-tests/internal/migration/migrations/test-class-student-progress-migration.php +++ b/tests/unit-tests/internal/migration/migrations/test-class-student-progress-migration.php @@ -4,6 +4,7 @@ use Sensei\Internal\Migration\Migrations\Student_Progress_Migration; use Sensei_Factory; +use Sensei_Utils; /** * Class Student_Progress_Migration_Test @@ -61,8 +62,8 @@ public function testRun_CommentsExist_ReturnsMatchingNumberOfInserts(): void { ) ); - \Sensei_Utils::start_user_on_course( 1, $course_id ); - \Sensei_Utils::user_start_lesson( 1, $lesson_id, true ); + Sensei_Utils::start_user_on_course( 1, $course_id ); + Sensei_Utils::user_start_lesson( 1, $lesson_id, true ); update_option( 'sensei_migrated_progress_last_comment_id', 0 ); @@ -94,11 +95,18 @@ public function testRun_CommentsExist_CreatesProgressMatchingEntriesInCustomTabl ), ) ); + $user_1 = $this->factory->user->create(); + $user_2 = $this->factory->user->create(); + update_post_meta( $quiz_id, '_quiz_lesson', $lesson_id ); - \Sensei_Utils::start_user_on_course( 1, $course_id ); - \Sensei_Utils::user_start_lesson( 1, $lesson_id, true ); - \Sensei_Utils::user_passed_quiz( $quiz_id, 1 ); + Sensei_Utils::start_user_on_course( $user_1, $course_id ); + Sensei_Utils::user_start_lesson( $user_1, $lesson_id, true ); + Sensei_Utils::user_passed_quiz( $quiz_id, $user_1 ); + + Sensei_Utils::start_user_on_course( $user_2, $course_id ); + Sensei_Utils::user_start_lesson( $user_2, $lesson_id, true ); + Sensei_Utils::user_passed_quiz( $quiz_id, $user_2 ); update_option( 'sensei_migrated_progress_last_comment_id', 0 ); @@ -109,19 +117,37 @@ public function testRun_CommentsExist_CreatesProgressMatchingEntriesInCustomTabl $actual_rows = $this->get_table_based_progress(); $expected = array( array( - 'user_id' => 1, + 'user_id' => $user_1, + 'post_id' => $course_id, + 'status' => 'in-progress', + 'type' => 'course', + ), + array( + 'user_id' => $user_1, + 'post_id' => $lesson_id, + 'status' => 'complete', + 'type' => 'lesson', + ), + array( + 'user_id' => $user_1, + 'post_id' => $quiz_id, + 'status' => 'passed', + 'type' => 'quiz', + ), + array( + 'user_id' => $user_2, 'post_id' => $course_id, 'status' => 'in-progress', 'type' => 'course', ), array( - 'user_id' => 1, + 'user_id' => $user_2, 'post_id' => $lesson_id, 'status' => 'complete', 'type' => 'lesson', ), array( - 'user_id' => 1, + 'user_id' => $user_2, 'post_id' => $quiz_id, 'status' => 'passed', 'type' => 'quiz', @@ -133,7 +159,7 @@ public function testRun_CommentsExist_CreatesProgressMatchingEntriesInCustomTabl private function get_table_based_progress(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}sensei_lms_progress" ); + $rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}sensei_lms_progress ORDER BY user_id" ); $result = array(); foreach ( $rows as $row ) { diff --git a/tests/unit-tests/internal/migration/migrations/validations/test-class-validation-error.php b/tests/unit-tests/internal/migration/migrations/validations/test-class-validation-error.php new file mode 100644 index 0000000000..4c26ef5f6c --- /dev/null +++ b/tests/unit-tests/internal/migration/migrations/validations/test-class-validation-error.php @@ -0,0 +1,67 @@ +get_message(); + + /* Assert. */ + $this->assertSame( 'foo', $actual ); + } + + public function testGetData_HasData_ReturnsData(): void { + /* Arrange. */ + $error = new Validation_Error( 'foo', [ 'bar' => 'baz' ] ); + + /* Act. */ + $actual = $error->get_data(); + + /* Assert. */ + $this->assertSame( [ 'bar' => 'baz' ], $actual ); + } + + public function testGetData_HasNoData_ReturnsEmptyArray(): void { + /* Arrange. */ + $error = new Validation_Error( 'foo' ); + + /* Act. */ + $actual = $error->get_data(); + + /* Assert. */ + $this->assertSame( [], $actual ); + } + + public function testHasData_HasData_ReturnsTrue(): void { + /* Arrange. */ + $error = new Validation_Error( 'foo', [ 'bar' => 'baz' ] ); + + /* Act. */ + $actual = $error->has_data(); + + /* Assert. */ + $this->assertTrue( $actual ); + } + + public function testHasData_HasNoData_ReturnsTrue(): void { + /* Arrange. */ + $error = new Validation_Error( 'foo' ); + + /* Act. */ + $actual = $error->has_data(); + + /* Assert. */ + $this->assertFalse( $actual ); + } +} diff --git a/tests/unit-tests/internal/migration/migrations/validations/test-progress-validation.php b/tests/unit-tests/internal/migration/migrations/validations/test-progress-validation.php new file mode 100644 index 0000000000..bf9696b49a --- /dev/null +++ b/tests/unit-tests/internal/migration/migrations/validations/test-progress-validation.php @@ -0,0 +1,267 @@ +factory = new Sensei_Factory(); + } + + public function testRun_WhenProgressMigrationIsComplete_HasNoErrors(): void { + /* Arrange. */ + $progress_validation = new Progress_Validation(); + + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertFalse( $progress_validation->has_errors() ); + } + + public function testRun_WhenProgressMigrationIsNotComplete_HasError(): void { + /* Arrange. */ + $progress_validation = new Progress_Validation(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'The progress migration is not complete. Please run the progress migration first.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasCourseProgressInTable_HasNoErrors(): void { + /* Arrange. */ + $course_id = $this->factory->course->create(); + $progress_validation = new Progress_Validation(); + + $this->directlyEnrolStudent( 1, $course_id ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertFalse( $progress_validation->has_errors() ); + } + + public function testRun_WhenHasNoCourseProgressInTable_HasError(): void { + /* Arrange. */ + global $wpdb; + $course_id = $this->factory->course->create(); + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Course_Progress_Repository( $wpdb ); + + $this->directlyEnrolStudent( 1, $course_id ); + $progress_repository->delete_for_course( $course_id ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Course tables based progress not found.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasCourseProgressButDataIsNotMatching_HasError(): void { + /* Arrange. */ + global $wpdb; + $course_id = $this->factory->course->create(); + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Course_Progress_Repository( $wpdb ); + + $this->directlyEnrolStudent( 1, $course_id ); + $progress = $progress_repository->get( $course_id, 1 ); + $progress->complete(); + $progress_repository->save( $progress ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Data mismatch between comments and tables based progress.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasLessonProgressInTable_HasNoErrors(): void { + /* Arrange. */ + $lesson_id = $this->factory->lesson->create(); + $progress_validation = new Progress_Validation(); + + Sensei_Utils::user_start_lesson( 1, $lesson_id ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertFalse( $progress_validation->has_errors() ); + } + + public function testRun_WhenHasNoLessonProgressInTable_HasError(): void { + /* Arrange. */ + global $wpdb; + $lesson_id = $this->factory->lesson->create(); + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Lesson_Progress_Repository( $wpdb ); + + Sensei_Utils::user_start_lesson( 1, $lesson_id ); + $progress_repository->delete_for_lesson( $lesson_id ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Lesson tables based progress not found.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasLessonProgressButDataIsNotMatching_HasError(): void { + /* Arrange. */ + global $wpdb; + $lesson_id = $this->factory->lesson->create(); + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Lesson_Progress_Repository( $wpdb ); + + Sensei_Utils::user_start_lesson( 1, $lesson_id ); + $progress = $progress_repository->get( $lesson_id, 1 ); + $progress->complete(); + $progress_repository->save( $progress ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Data mismatch between comments and tables based progress.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasQuizProgressInTable_HasNoErrors(): void { + /* Arrange. */ + $course_data = $this->factory->get_course_with_lessons(); + $lesson_id = $course_data['lesson_ids'][0]; + $quiz_id = $course_data['quiz_ids'][0]; + $progress_validation = new Progress_Validation(); + + add_filter( 'sensei_is_enrolled', '__return_true' ); + $answers = $this->factory->generate_user_quiz_answers( $quiz_id ); + Sensei_Quiz::save_user_answers( $answers, [], $lesson_id, 1 ); + Sensei()->quiz->maybe_create_quiz_progress( $quiz_id, 1 ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertFalse( $progress_validation->has_errors() ); + } + + public function testRun_WhenHasNoQuizProgressInTable_HasError(): void { + /* Arrange. */ + global $wpdb; + $course_data = $this->factory->get_course_with_lessons(); + $lesson_id = $course_data['lesson_ids'][0]; + $quiz_id = $course_data['quiz_ids'][0]; + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Quiz_Progress_Repository( $wpdb ); + + add_filter( 'sensei_is_enrolled', '__return_true' ); + $answers = $this->factory->generate_user_quiz_answers( $quiz_id ); + Sensei_Quiz::save_user_answers( $answers, [], $lesson_id, 1 ); + Sensei()->quiz->maybe_create_quiz_progress( $quiz_id, 1 ); + $progress_repository->delete_for_quiz( $quiz_id ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Quiz tables based progress not found.', + $this->get_first_error_message( $progress_validation ) + ); + } + + public function testRun_WhenHasQuizProgressButDataIsNotMatching_HasError(): void { + /* Arrange. */ + global $wpdb; + $course_data = $this->factory->get_course_with_lessons(); + $lesson_id = $course_data['lesson_ids'][0]; + $quiz_id = $course_data['quiz_ids'][0]; + $progress_validation = new Progress_Validation(); + $progress_repository = new Tables_Based_Quiz_Progress_Repository( $wpdb ); + + add_filter( 'sensei_is_enrolled', '__return_true' ); + $answers = $this->factory->generate_user_quiz_answers( $quiz_id ); + Sensei_Quiz::save_user_answers( $answers, [], $lesson_id, 1 ); + Sensei()->quiz->maybe_create_quiz_progress( $quiz_id, 1 ); + $progress = $progress_repository->get( $quiz_id, 1 ); + $progress->pass(); + $progress_repository->save( $progress ); + $this->mask_migration_as_complete(); + + /* Act. */ + $progress_validation->run(); + + /* Assert. */ + $this->assertSame( + 'Data mismatch between comments and tables based progress.', + $this->get_first_error_message( $progress_validation ) + ); + } + + private function mask_migration_as_complete(): void { + update_option( Migration_Job_Scheduler::COMPLETED_OPTION_NAME, microtime( true ) ); + } + + private function get_first_error_message( Progress_Validation $progress_validation ): ?string { + $errors = $progress_validation->get_errors(); + + if ( empty( $errors ) ) { + return null; + } + + return $errors[0]->get_message(); + } +}