diff --git a/composer.json b/composer.json index ac8a38e2..cfa3923b 100644 --- a/composer.json +++ b/composer.json @@ -59,10 +59,16 @@ "admin/", "includes/classes/", "includes/deprecated/" - ] + ], + "psr-4": { + "EqualizeDigital\\AccessibilityChecker\\": "includes/classes/" + } }, "autoload-dev": { - "classmap": [] + "classmap": [], + "psr-4": { + "EqualizeDigital\\AccessibilityChecker\\Tests\\TestHelpers\\": "tests/phpunit/TestHelpers/" + } }, "scripts": { "lint": [ diff --git a/includes/classes/WPCLI/BootstrapCLI.php b/includes/classes/WPCLI/BootstrapCLI.php new file mode 100644 index 00000000..35e9f11b --- /dev/null +++ b/includes/classes/WPCLI/BootstrapCLI.php @@ -0,0 +1,107 @@ +wp_cli = $wp_cli ? $wp_cli : new WP_CLI(); + } + + /** + * Register the WP-CLI commands by looping through the commands array and adding each command. + * + * @since 1.15.0 + * + * @return void + */ + public function register() { + // Bail if not running in WP_CLI. + if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { + return; + } + + /** + * Filter the list of classes that hold the commands to be registered. + * + * @since 1.15.0 + * + * @param CLICommandInterface[] $commands array of classes to register as commands. + */ + $commands = apply_filters( 'edac_filter_command_classes', $this->commands ); + + foreach ( $commands as $command ) { + // All commands must follow the interface. + if ( ! is_subclass_of( $command, CLICommandInterface::class, true ) ) { + continue; + } + + try { + $this->wp_cli::add_command( + $command::get_name(), + $command, + $command::get_args() + ); + } catch ( Exception $e ) { + $this->wp_cli::warning( + sprintf( + // translators: 1: a php classname, 2: an error message that was thrown about why this failed to register. + esc_html__( 'Failed to register command %1$s because %2$s', 'accessibility-checker' ), + $command, + $e->getMessage() + ) + ); + } + } + } +} diff --git a/includes/classes/WPCLI/Command/CLICommandInterface.php b/includes/classes/WPCLI/Command/CLICommandInterface.php new file mode 100644 index 00000000..814d7d01 --- /dev/null +++ b/includes/classes/WPCLI/Command/CLICommandInterface.php @@ -0,0 +1,40 @@ +wp_cli = $wp_cli ?? new WP_CLI(); + } + + /** + * Get the name of the command + * + * @return string + */ + public static function get_name(): string { + return 'accessibility-checker delete-stats'; + } + + /** + * Get the arguments for the command + * + * @return array + */ + public static function get_args(): array { + return [ + 'synopsis' => [ + [ + 'type' => 'positional', + 'name' => 'post_id', + 'description' => esc_html__( 'The ID of the post to delete stats for.', 'accessibility-checker' ), + 'optional' => true, + 'default' => 0, + 'repeating' => false, + ], + ], + ]; + } + + /** + * Delete the accessibility-checker stats for a given post ID. + * + * @param array $options This is the positional argument, the post ID in this case. + * @param array $arguments This is the associative argument, not used in this command but kept for consistency with cli commands using this pattern. + * + * @return void + * @throws ExitException If the post ID is not provided, does not exist, or the class we need isn't available. + */ + public function __invoke( array $options = [], array $arguments = [] ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $post_id = $options[0] ?? 0; + + if ( 0 === $post_id ) { + $this->wp_cli::error( esc_html__( 'No Post ID provided.', 'accessibility-checker' ) ); + } + + $post_exists = (bool) get_post( $post_id ); + + if ( ! $post_exists ) { + $this->wp_cli::error( + sprintf( + // translators: 1: a post ID. + esc_html__( 'Post ID %1$s does not exist.', 'accessibility-checker' ), + $post_id + ) + ); + return; + } + + Purge_Post_Data::delete_post( $post_id ); + $this->wp_cli::success( + sprintf( + // translators: 1: a post ID. + esc_html__( 'Stats of %1$s deleted.', 'accessibility-checker' ), + $post_id + ) + ); + } +} diff --git a/includes/classes/WPCLI/Command/GetSiteStats.php b/includes/classes/WPCLI/Command/GetSiteStats.php new file mode 100644 index 00000000..6a0314b8 --- /dev/null +++ b/includes/classes/WPCLI/Command/GetSiteStats.php @@ -0,0 +1,128 @@ +wp_cli = $wp_cli ?? new WP_CLI(); + } + + /** + * Get the name of the command. + * + * @since 1.15.0 + * + * @return string + */ + public static function get_name(): string { + return 'accessibility-checker get-site-stats'; + } + + /** + * Get the arguments for the command + * + * @since 1.15.0 + * + * @return array + */ + public static function get_args(): array { + return [ + 'synopsis' => [ + [ + 'type' => 'assoc', + 'name' => 'stat', + 'description' => esc_html__( 'Keys to show in the results. Defaults to all keys.', 'accessibility-checker' ), + 'optional' => true, + 'default' => null, + 'repeating' => true, + ], + [ + 'type' => 'flag', + 'name' => 'clear-cache', + 'description' => esc_html__( 'Clear the cache before retrieving the stats (can be intensive).', 'accessibility-checker' ), + 'repeating' => false, + 'optional' => true, + ], + ], + ]; + } + + /** + * Gets the accessibility-checker stats for the whole site. Use the --clear-cache flag to clear the cache before retrieving the stats. + * + * @since 1.15.0 + * + * @param array $options The positional argument, none is this case. + * @param array $arguments The associative arguments, the stat keys in this case. + * + * @return void + * @throws ExitException If the post ID does not exist, or the class we need isn't available. + */ + public function __invoke( array $options = [], array $arguments = [] ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! empty( $arguments['clear-cache'] ) ) { + // Clear the cache. + ( new Scans_Stats() )->clear_cache(); + } + + $all_stats = ( new Scans_Stats() )->summary(); + + if ( ! empty( $arguments['stat'] ) ) { + $items_to_return = []; + $requested_stats = explode( ',', $arguments['stat'] ); + foreach ( $requested_stats as $key ) { + $stats_key = trim( $key ); + if ( ! isset( $all_stats[ $stats_key ] ) ) { + $this->wp_cli::error( + sprintf( + // translators: 1: a stat key that was requested but not found. + esc_html__( 'Stat key: %1$s not found in stats.', 'accessibility-checker' ), + $stats_key + ) + ); + return; + } + $items_to_return[ $stats_key ] = $all_stats[ $stats_key ]; + } + } + + if ( isset( $items_to_return ) ) { + $this->wp_cli::success( wp_json_encode( $items_to_return, JSON_PRETTY_PRINT ) ); + return; + } + + $this->wp_cli::success( wp_json_encode( $all_stats, JSON_PRETTY_PRINT ) ); + } +} diff --git a/includes/classes/WPCLI/Command/GetStats.php b/includes/classes/WPCLI/Command/GetStats.php new file mode 100644 index 00000000..4cd45a5f --- /dev/null +++ b/includes/classes/WPCLI/Command/GetStats.php @@ -0,0 +1,177 @@ +wp_cli = $wp_cli ?? new WP_CLI(); + } + + /** + * Get the name of the command. + * + * @since 1.15.0 + * + * @return string + */ + public static function get_name(): string { + return 'accessibility-checker get-stats'; + } + + /** + * Get the arguments for the command + * + * @since 1.15.0 + * + * @return array + */ + public static function get_args(): array { + return [ + 'synopsis' => [ + [ + 'type' => 'positional', + 'name' => 'post_id', + 'description' => esc_html__( 'The ID of the post to get stats for.', 'accessibility-checker' ), + 'optional' => true, + 'default' => 0, + 'repeating' => false, + ], + [ + 'type' => 'assoc', + 'name' => 'stat', + 'description' => sprintf( + // translators: 1: a comma separated list of valid stats keys that should not be translated. + 'Keys to show in the results. Defaults to all keys. Pass items in as a comma separated list if you want multiple. Valid keys are: %1$s.', + implode( ', ', self::$valid_stats ) + ), + 'optional' => true, + 'default' => null, + 'repeating' => true, + ], + ], + ]; + } + + /** + * Gets the accessibility-checker stats for a given post ID. + * + * @since 1.15.0 + * + * @param array $options The positional argument, the post ID in this case. + * @param array $arguments The associative argument, the stat key in this case. + * + * @return void + * @throws ExitException If the post ID does not exist, or the class we need isn't available. + */ + public function __invoke( array $options = [], array $arguments = [] ) { + $post_id = $options[0] ?? null; + + $post_exists = (bool) get_post( $post_id ); + + if ( ! $post_exists ) { + $this->wp_cli::error( + sprintf( + // translators: 1: a post ID. + esc_html__( 'Post ID %1$d does not exist.', 'accessibility-checker' ), + $post_id + ) + ); + return; + } + + $stats = ( new Summary_Generator( $post_id ) )->generate_summary(); + + if ( + empty( $stats ) || + ( 100 === (int) $stats['passed_tests'] && 0 === (int) $stats['ignored'] ) + ) { + $this->wp_cli::success( + sprintf( + // translators: 1: a post ID. + esc_html__( 'Either the post is not yet scanned or all tests passed for post ID %1$d.', 'accessibility-checker' ), + $post_id + ) + ); + return; + } + + if ( ! empty( $arguments['stat'] ) ) { + $items_to_return = []; + $requested_stats = explode( ',', $arguments['stat'] ); + foreach ( $requested_stats as $key ) { + $stats_key = trim( $key ); + if ( ! in_array( $stats_key, self::$valid_stats, true ) || ! isset( $stats[ $stats_key ] ) ) { + $this->wp_cli::error( + sprintf( + // translators: 1: a stat key, 2: a comma separated list of valid stats keys. + 'Invalid stat key: %1$s. Valid keys are: %2$s.', + $stats_key, + implode( ', ', self::$valid_stats ) + ) + ); + + return; + } + $items_to_return[ $stats_key ] = $stats[ $stats_key ]; + } + + if ( $items_to_return ) { + $this->wp_cli::success( wp_json_encode( $items_to_return, JSON_PRETTY_PRINT ) ); + return; + } + } + + $this->wp_cli::success( wp_json_encode( $stats, JSON_PRETTY_PRINT ) ); + } +} diff --git a/includes/classes/class-plugin.php b/includes/classes/class-plugin.php index c0170b93..6bb0f558 100644 --- a/includes/classes/class-plugin.php +++ b/includes/classes/class-plugin.php @@ -9,6 +9,7 @@ use EDAC\Admin\Admin; use EDAC\Admin\Meta_Boxes; +use EqualizeDigital\AccessibilityChecker\WPCLI\BootstrapCLI; /** * Main plugin functionality class. @@ -30,6 +31,12 @@ public function __construct() { // The REST api must load if admin or not. $rest_api = new REST_Api(); $rest_api->init_hooks(); + + // When WP CLI is enabled, load the CLI commands. + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $cli = new BootstrapCLI(); + $cli->register(); + } } /** diff --git a/phpcs.xml b/phpcs.xml index 18e8384a..3752d709 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,6 +1,6 @@ - Accessibility Checker Wordpress Coding Standards + Accessibility Checker WordPress Coding Standards . @@ -147,4 +147,12 @@ + + + + + + + + diff --git a/tests/phpunit/TestHelpers/DatabaseHelpers.php b/tests/phpunit/TestHelpers/DatabaseHelpers.php new file mode 100644 index 00000000..221e1a18 --- /dev/null +++ b/tests/phpunit/TestHelpers/DatabaseHelpers.php @@ -0,0 +1,71 @@ +edac_update_database(); + } + /** + * Insert a record to the database for a given post. + * + * @param WP_Post $post The post to insert the record for. + * + * @return void + */ + public static function insert_test_issue_to_db( WP_Post $post ): void { + + global $wpdb; + $table_name = $wpdb->prefix . 'accessibility_checker'; + $wpdb->insert( // phpcs:ignore WordPress.DB -- using direct query for testing. + $table_name, + [ + 'postid' => $post->ID, + 'siteid' => get_current_blog_id(), + 'type' => $post->post_type, + 'rule' => 'empty_paragraph_tag', + 'ruletype' => 'warning', + 'object' => '

', + 'recordcheck' => 1, + 'user' => get_current_user_id(), + 'ignre' => 0, + 'ignre_user' => null, + 'ignre_date' => null, + 'ignre_comment' => null, + 'ignre_global' => 0, + ] + ); + } + + /** + * Drops the table for the plugin if it exists. + * + * Used for cleanup after tests. + * + * @return void + */ + public static function drop_table() { + global $wpdb; + $table_name = $wpdb->prefix . 'accessibility_checker'; + $wpdb->query( 'DROP TABLE IF EXISTS ' . $table_name ); // phpcs:ignore WordPress.DB -- query for a unit test. + } +} diff --git a/tests/phpunit/TestHelpers/Mocks/Mock_WP_CLI.php b/tests/phpunit/TestHelpers/Mocks/Mock_WP_CLI.php new file mode 100644 index 00000000..728a713b --- /dev/null +++ b/tests/phpunit/TestHelpers/Mocks/Mock_WP_CLI.php @@ -0,0 +1,124 @@ +get_subcommands() ); + + $bootstrap_cli = new BootstrapCLI( new WP_CLI() ); + $bootstrap_cli->register(); + + $commands = WP_CLI::get_root_command(); + $command_count_after = count( $commands->get_subcommands() ); + + // check if the number of commands has increased after register is called. + $this->assertGreaterThan( $command_count, $command_count_after ); + } + + /** + * Test the bootstrap CLI command with a mock that throws an exception when + * adding commands. + */ + public function test_bootstrap_cli_command_with_exception() { + WP_CLI::set_add_command_should_throw( true ); + + $bootstrap_cli = new BootstrapCLI( new WP_CLI() ); + + ob_start(); + $bootstrap_cli->register(); + $output = ob_get_clean(); + + // check if the output contains the expected exception message. + $this->assertStringStartsWith( 'Warning: Failed to register command', $output ); + } +} diff --git a/tests/phpunit/includes/classes/WPCLI/Commands/DeleteStatsTest.php b/tests/phpunit/includes/classes/WPCLI/Commands/DeleteStatsTest.php new file mode 100644 index 00000000..dc25055e --- /dev/null +++ b/tests/phpunit/includes/classes/WPCLI/Commands/DeleteStatsTest.php @@ -0,0 +1,80 @@ +delete_stats = new DeleteStats( new Mock_WP_CLI() ); + DatabaseHelpers::create_table(); + parent::setUp(); + } + + /** + * Drop the table to clean up after tests. + */ + protected function tearDown(): void { + DatabaseHelpers::drop_table(); + parent::tearDown(); + } + + /** + * Test the delete stats command errors if the post doesn't exist. + */ + public function test_delete_stats_command_errors_when_post_does_not_exist() { + $non_existent_id = 132456789; + + ob_start(); + $this->delete_stats->__invoke( [ $non_existent_id ], [] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Error: Post ID ' . $non_existent_id . ' does not exist', $output ); + } + + /** + * Test the delete stats command errors if no post ID is passed. + */ + public function test_delete_stats_command_errors_when_no_id_is_passed() { + ob_start(); + $this->delete_stats->__invoke( [], [] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Error: No Post ID provided.', $output ); + } + + /** + * Test the delete stats command deletes the stats. + */ + public function test_delete_stats_command_deletes_stats() { + $post_id = $this->factory()->post->create(); + DatabaseHelpers::insert_test_issue_to_db( get_post( $post_id ) ); + + global $wpdb; + $table_name = $wpdb->prefix . 'accessibility_checker'; + $stats_before = $wpdb->get_results( "SELECT * FROM $table_name WHERE postid = $post_id" ); // phpcs:ignore WordPress.DB -- Querying for testing purposes. + $this->assertEquals( 1, count( $stats_before ) ); + + ob_start(); + $this->delete_stats->__invoke( [ $post_id ], [] ); + $output = ob_get_clean(); + + $this->assertStringStartsWith( 'Success: Stats of ' . $post_id . ' deleted', $output ); + + // make sure the issue is actually deleted from the database. + $stats_after = $wpdb->get_results( "SELECT * FROM $table_name WHERE postid = $post_id" ); // phpcs:ignore WordPress.DB -- Querying for testing purposes. + $this->assertEmpty( $stats_after ); + } +} diff --git a/tests/phpunit/includes/classes/WPCLI/Commands/GetSiteStatsTest.php b/tests/phpunit/includes/classes/WPCLI/Commands/GetSiteStatsTest.php new file mode 100644 index 00000000..82fc2929 --- /dev/null +++ b/tests/phpunit/includes/classes/WPCLI/Commands/GetSiteStatsTest.php @@ -0,0 +1,120 @@ +get_site_stats = new GetSiteStats( new Mock_WP_CLI() ); + + DatabaseHelpers::create_table(); + + parent::setUp(); + } + + /** + * Drop the table to clean up after tests. + */ + protected function tearDown(): void { + DatabaseHelpers::drop_table(); + parent::tearDown(); + } + + /** + * Test the get site stats command can complete when run. + */ + public function test_get_site_stats_command_can_complete() { + ob_start(); + $this->get_site_stats->__invoke( [], [] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Success: {', $output ); + } + + /** + * Test the get site stats command can return filtered stats for one key or multiple keys. + */ + public function test_get_site_stats_command_can_return_filtered_stats() { + + ob_start(); + $this->get_site_stats->__invoke( [], [ 'stat' => 'rule_count' ] ); + $stats = ob_get_clean(); + + $this->assertStringContainsString( 'Success: {', $stats ); + + $this->assertStringContainsString( 'Success: {', $stats ); + + $stats_array = json_decode( + html_entity_decode( + str_replace( 'Success: ', '', $stats ) + ), + true + ); + + $this->assertEquals( 1, count( $stats_array ) ); + $this->assertArrayHasKey( 'rule_count', $stats_array ); + + ob_start(); + $this->get_site_stats->__invoke( [], [ 'stat' => 'rule_count,tests_count' ] ); + $stats = ob_get_clean(); + + $this->assertStringContainsString( 'Success: {', $stats ); + + $stats_array = json_decode( + html_entity_decode( + str_replace( 'Success: ', '', $stats ) + ), + true + ); + + $this->assertEquals( 2, count( $stats_array ) ); + $this->assertArrayHasKey( 'rule_count', $stats_array ); + $this->assertArrayHasKey( 'tests_count', $stats_array ); + } + + /** + * Test the get site stats command errors if the requested stat key doesn't exist. + */ + public function test_get_site_stats_command_errors_when_stat_key_does_not_exist() { + ob_start(); + $this->get_site_stats->__invoke( [], [ 'stat' => 'non_existent_key' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Error: Stat key: non_existent_key not found in stats.', $output ); + } + + /** + * Test the get site stats command can clear the cache. + */ + public function test_get_site_stats_command_can_clear_cache() { + ob_start(); + $this->get_site_stats->__invoke( [], [] ); + $stats_initial = ob_get_clean(); + + DatabaseHelpers::insert_test_issue_to_db( get_post( $this->factory()->post->create() ) ); + + ob_start(); + $this->get_site_stats->__invoke( [], [ 'clear-cache' => true ] ); + $stats_after_clear = ob_get_clean(); + + $this->assertNotEquals( $stats_initial, $stats_after_clear ); + } +} diff --git a/tests/phpunit/includes/classes/WPCLI/Commands/GetStatsTest.php b/tests/phpunit/includes/classes/WPCLI/Commands/GetStatsTest.php new file mode 100644 index 00000000..fd146694 --- /dev/null +++ b/tests/phpunit/includes/classes/WPCLI/Commands/GetStatsTest.php @@ -0,0 +1,162 @@ +register(); + $this->get_stats = new GetStats( $wp_cli ); + + // create database tables if they don't exist. + DatabaseHelpers::create_table(); + + parent::setUp(); + } + + /** + * Drop the table to clean up after tests. + * + * @return void + */ + protected function tearDown(): void { + DatabaseHelpers::drop_table(); + + parent::tearDown(); + } + + /** + * Test the get stats command errors if the post doesn't exist/ + */ + public function test_get_stats_command_errors_when_post_does_not_exist() { + + $non_existent_id = 132456789; + + ob_start(); + $this->get_stats->__invoke( [ $non_existent_id ], [] ); + $stats = ob_get_clean(); + + // check we have the expected error. + $this->assertEquals( 'Error: Post ID ' . $non_existent_id . ' does not exist.', $stats ); + } + + /** + * Test the get stats command can complete when no stats exist for a post. + */ + public function test_get_stats_command_completes_when_no_stats_exist_for_post() { + + $post_id = $this->factory()->post->create(); + + ob_start(); + $this->get_stats->__invoke( [ $post_id ], [] ); + $stats = ob_get_clean(); + + $this->assertStringStartsWith( 'Success: Either the post is not yet scanned or all tests passed', $stats ); + } + + /** + * Test the get stats command can get stats for a post can get the stats. + */ + public function test_get_stats_command_returns_results_when_stats_exist_for_post() { + $post_id = $this->factory()->post->create(); + $post = get_post( $post_id ); + + DatabaseHelpers::insert_test_issue_to_db( $post ); + + ob_start(); + $this->get_stats->__invoke( [ $post_id ], [] ); + $stats = ob_get_clean(); + + $this->assertStringStartsWith( 'Success: {', $stats ); + } + + /** + * Test that the get stats command can get just the requested stats keys. + * + * @return void + */ + public function test_get_stats_can_get_filtered_stats_when_requested() { + $post_id = $this->factory()->post->create(); + $post = get_post( $post_id ); + + DatabaseHelpers::insert_test_issue_to_db( $post ); + + ob_start(); + $this->get_stats->__invoke( [ $post_id ], [ 'stat' => 'passed_tests' ] ); + $stats = ob_get_clean(); + + // check the output is still a success message. + $this->assertStringStartsWith( 'Success: {', $stats ); + + $stats_array = json_decode( + html_entity_decode( + str_replace( 'Success: ', '', $stats ) + ), + true + ); + + // is only one key long and is the key we requested. + $this->assertCount( 1, $stats_array ); + $this->assertArrayHasKey( 'passed_tests', $stats_array ); + + ob_start(); + $this->get_stats->__invoke( [ $post_id ], [ 'stat' => 'passed_tests, errors, warnings' ] ); + $stats = ob_get_clean(); + + // check the output is still a success message. + $this->assertStringStartsWith( 'Success: {', $stats ); + + $stats_array = json_decode( + html_entity_decode( + str_replace( 'Success: ', '', $stats ) + ), + true + ); + + // is 3 keys. + $this->assertCount( 3, $stats_array ); + $this->assertArrayHasKey( 'passed_tests', $stats_array ); + $this->assertArrayHasKey( 'errors', $stats_array ); + $this->assertArrayHasKey( 'warnings', $stats_array ); + } + + /** + * Test the get stats command errors when filtered stats are requested for non-existent keys. + */ + public function test_get_stats_errors_when_filtered_stats_are_requested_for_non_existent_keys() { + $post_id = $this->factory()->post->create(); + $post = get_post( $post_id ); + + DatabaseHelpers::insert_test_issue_to_db( $post ); + + ob_start(); + $this->get_stats->__invoke( [ $post_id ], [ 'stat' => 'a_non_existant_stat' ] ); + $stats = ob_get_clean(); + + $this->assertStringStartsWith( 'Error: Invalid stat key', $stats ); + } +}