diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 9aabbb7..eef2b75 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -35,8 +35,16 @@ jobs: php-version: ${{ matrix.php-version }} tools: phpunit-polyfills:1.1 + - name: Install SVN + run: sudo apt install -y subversion + - name: Setup tests run: bash bin/install-wp-tests.sh wordpress_tests root root 127.0.0.1 latest true + - name: Prime the WordPress install for tests + id: prime-tests + run: XDEBUG_MODE=off phpunit${{ matrix.multisite && ' -c tests/phpunit/multisite.xml' || '' }} --filter gha_install_wp > /dev/null 2>&1 + - name: Run tests - run: XDEBUG_MODE=off phpunit${{ matrix.multisite && ' -c tests/phpunit/multisite.xml' || '' }} + if: steps.prime-tests.outcome == 'success' + run: XDEBUG_MODE=off WP_TESTS_SKIP_INSTALL=1 phpunit${{ matrix.multisite && ' -c tests/phpunit/multisite.xml' || '' }} diff --git a/includes/class-admin-settings.php b/includes/class-admin-settings.php index 414b604..b075cc3 100644 --- a/includes/class-admin-settings.php +++ b/includes/class-admin-settings.php @@ -506,6 +506,8 @@ public function register_settings() { /** * The Fields API which any CMS should have in its core but something we dont, hence this ugly hack. * + * @codeCoverageIgnore Test with E2E tests instead. + * * @param array $args The Field Parameters. * * @return void Echos the Field HTML. diff --git a/includes/class-debug.php b/includes/class-debug.php index eba51fd..81ab276 100644 --- a/includes/class-debug.php +++ b/includes/class-debug.php @@ -63,7 +63,7 @@ private static function verify_filesystem( $wp_filesystem ) { /** * Log error in file write fails only if debug is set to true. This is a valid use case. */ - error_log( 'AspireUpdate - Could not open or write to the file system. Check file system permissions to debug log directory.' ); + error_log( 'AspireUpdate - Could not open or write to the file system. Check file system permissions to debug log directory.' ); // @codeCoverageIgnore // phpcs:enable } return false; diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 081e4b7..66ac36d 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -61,9 +61,25 @@ - /tests/phpunit/tests/* + /tests/phpunit/* + /tests/phpunit/* + + + + /tests/phpunit/tests/* + + + + /tests/phpunit/* + + + /tests/phpunit/* + + + /tests/phpunit/* + diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 3899953..186c80a 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -40,5 +40,9 @@ function _manually_load_plugin() { // Start up the WP testing environment. require "{$_tests_dir}/includes/bootstrap.php"; +// Load unit test mocks and fakes. +require __DIR__ . '/includes/class-ap-fake-filesystem.php'; + // Load unit test abstract classes. require __DIR__ . '/includes/AdminSettings_UnitTestCase.php'; +require __DIR__ . '/includes/Debug_UnitTestCase.php'; diff --git a/tests/phpunit/includes/Debug_UnitTestCase.php b/tests/phpunit/includes/Debug_UnitTestCase.php new file mode 100644 index 0000000..0058fd0 --- /dev/null +++ b/tests/phpunit/includes/Debug_UnitTestCase.php @@ -0,0 +1,116 @@ +setAccessible( true ); + self::$log_file = $get_file_path->invoke( null ); + $get_file_path->setAccessible( false ); + + if ( file_exists( self::$log_file ) ) { + unlink( self::$log_file ); + } + + if ( isset( $GLOBALS['wp_filesystem'] ) ) { + self::$default_filesystem = $GLOBALS['wp_filesystem']; + } else { + self::$default_filesystem = false; + } + } + + /** + * Filters the filesystem method before each test runs. + * + * Filters are removed in the tear_down() parent method. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + add_filter( + 'filesystem_method', + static function () { + return 'direct'; + } + ); + } + + /** + * Delete the log file and restores the filesystem after each test runs. + * + * @return void + */ + public function tear_down() { + if ( file_exists( self::$log_file ) ) { + unlink( self::$log_file ); + } + + if ( false === self::$default_filesystem ) { + unset( $GLOBALS['wp_filesystem'] ); + } else { + $GLOBALS['wp_filesystem'] = self::$default_filesystem; + } + + parent::tear_down(); + } + + /** + * Creates a fake filesystem. + * + * @param bool|null $exists Whether paths should exist. + * Default null uses default implemenation. + * @param bool|null $is_readable Whether paths should be readable. + * Default null uses default implemenation. + * @param bool|null $is_writable Whether paths should be writable. + * Default null uses default implemenation. + */ + public function get_fake_filesystem( $exists = null, $is_readable = null, $is_writable = null ) { + $hash = ( null === $exists ? '-1' : (int) $exists ) . ',' . + ( null === $is_readable ? '-1' : (int) $is_readable ) . ',' . + ( null === $is_writable ? '-1' : (int) $is_writable ); + + if ( ! isset( self::$filesystems[ $hash ] ) ) { + self::$filesystems[ $hash ] = new AP_FakeFilesystem( $exists, $is_readable, $is_writable ); + } + + return self::$filesystems[ $hash ]; + } +} diff --git a/tests/phpunit/includes/class-ap-fake-filesystem.php b/tests/phpunit/includes/class-ap-fake-filesystem.php new file mode 100644 index 0000000..d2de759 --- /dev/null +++ b/tests/phpunit/includes/class-ap-fake-filesystem.php @@ -0,0 +1,82 @@ +exists = $exists; + $this->is_readable = $is_readable; + $this->is_writable = $is_writable; + } + + /** + * Checks whether a path exists. + * + * @param string $path The path to check. + * @return bool Whether the path exists. + */ + public function exists( $path ) { + if ( null === $this->exists ) { + return parent::exists( $path ); + } + return $this->exists; + } + + /** + * Checks whether a path is readable. + * + * @param string $path The path to check. + * @return bool Whether the path is readable. + */ + public function is_readable( $path ) { + if ( null === $this->is_readable ) { + return parent::is_readable( $path ); + } + return $this->is_readable; + } + + /** + * Checks whether a path is writable. + * + * @param string $path The path to check. + * @return bool Whether the path is writable. + */ + public function is_writable( $path ) { + if ( null === $this->is_writable ) { + return parent::is_writable( $path ); + } + return $this->is_writable; + } +} diff --git a/tests/phpunit/tests/Debug/Debug_ClearTest.php b/tests/phpunit/tests/Debug/Debug_ClearTest.php new file mode 100644 index 0000000..d6882c9 --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_ClearTest.php @@ -0,0 +1,106 @@ +assertWPError( + AspireUpdate\Debug::clear(), + 'A WP_Error object was not returned.' + ); + + $this->assertFileDoesNotExist( + self::$log_file, + 'The log file was created.' + ); + } + + /** + * Test that a WP_Error object is returned when the log file doesn't exist. + * + * @covers \AspireUpdate\Debug::init_filesystem + * @covers \AspireUpdate\Debug::verify_filesystem + * @covers \AspireUpdate\Debug::get_file_path + */ + public function test_should_return_wp_error_when_log_file_does_not_exist() { + $this->assertWPError( + AspireUpdate\Debug::clear(), + 'A WP_Error object was not returned.' + ); + + $this->assertFileDoesNotExist( + self::$log_file, + 'The log file was created.' + ); + } + + /** + * Test that a WP_Error object is returned when the log file isn't writable. + * + * @covers \AspireUpdate\Debug::init_filesystem + * @covers \AspireUpdate\Debug::verify_filesystem + * @covers \AspireUpdate\Debug::get_file_path + */ + public function test_should_return_wp_error_when_log_file_is_not_writable() { + global $wp_filesystem; + + // Backup and replace the filesystem object. + $wp_filesystem = $this->get_fake_filesystem( true, true, false ); + + $actual = AspireUpdate\Debug::clear(); + + $this->assertWPError( + $actual, + 'A WP_Error was not returned.' + ); + + $this->assertFileDoesNotExist( + self::$log_file, + 'The log file was created.' + ); + } + + /** + * Test that the log file is cleared. + */ + public function test_should_clear_log_file() { + file_put_contents( + self::$log_file, + "First line\r\nSecond line\r\nThird line" + ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created before testing.' + ); + + AspireUpdate\Debug::clear(); + + $this->assertFileExists( + self::$log_file, + 'The log file was deleted.' + ); + + $this->assertSame( + '', + file_get_contents( self::$log_file ), + 'The log file was not cleared.' + ); + } +} diff --git a/tests/phpunit/tests/Debug/Debug_LogRequestTest.php b/tests/phpunit/tests/Debug/Debug_LogRequestTest.php new file mode 100644 index 0000000..e16ba57 --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_LogRequestTest.php @@ -0,0 +1,104 @@ +assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when debug types are not an array. + */ + public function test_should_not_write_to_log_file_when_debug_types_are_not_an_array() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', 'request' ); + + AspireUpdate\Debug::log_request( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when request debugging is disabled. + */ + public function test_should_not_write_to_log_file_when_request_debugging_is_disabled() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', [ 'response', 'string' ] ); + + AspireUpdate\Debug::log_request( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that the message is written to the log file. + * + * @dataProvider data_debug_types + * + * @param array $debug_types An array of enabled debug types. + */ + public function test_should_write_to_log_file( $debug_types ) { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', $debug_types ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log_request( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was created.' + ); + + $this->assertStringContainsString( + $message, + file_get_contents( self::$log_file ), + 'The message was not logged.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_debug_types() { + return [ + 'just "request"' => [ + 'debug_types' => [ 'request' ], + ], + '"request" at the start of the array"' => [ + 'debug_types' => [ 'request', 'response' ], + ], + '"request" in the middle of the array"' => [ + 'debug_types' => [ 'string', 'request', 'response' ], + ], + '"request" at the end of the array"' => [ + 'debug_types' => [ 'string', 'response', 'request' ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/Debug/Debug_LogResponseTest.php b/tests/phpunit/tests/Debug/Debug_LogResponseTest.php new file mode 100644 index 0000000..8be3605 --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_LogResponseTest.php @@ -0,0 +1,104 @@ +assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when debug types are not an array. + */ + public function test_should_not_write_to_log_file_when_debug_types_are_not_an_array() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', 'response' ); + + AspireUpdate\Debug::log_response( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when response debugging is disabled. + */ + public function test_should_not_write_to_log_file_when_response_debugging_is_disabled() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', [ 'request', 'string' ] ); + + AspireUpdate\Debug::log_response( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that the message is written to the log file. + * + * @dataProvider data_debug_types + * + * @param array $debug_types An array of enabled debug types. + */ + public function test_should_write_to_log_file( $debug_types ) { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', $debug_types ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log_response( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was created.' + ); + + $this->assertStringContainsString( + $message, + file_get_contents( self::$log_file ), + 'The message was not logged.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_debug_types() { + return [ + 'just "response"' => [ + 'debug_types' => [ 'response' ], + ], + '"response" at the start of the array"' => [ + 'debug_types' => [ 'response', 'request' ], + ], + '"response" in the middle of the array"' => [ + 'debug_types' => [ 'string', 'response', 'request' ], + ], + '"response" at the end of the array"' => [ + 'debug_types' => [ 'string', 'request', 'response' ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/Debug/Debug_LogStringTest.php b/tests/phpunit/tests/Debug/Debug_LogStringTest.php new file mode 100644 index 0000000..14508a6 --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_LogStringTest.php @@ -0,0 +1,104 @@ +assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when debug types are not an array. + */ + public function test_should_not_write_to_log_file_when_debug_types_are_not_an_array() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', 'string' ); + + AspireUpdate\Debug::log_string( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that nothing is written to the log file when string debugging is disabled. + */ + public function test_should_not_write_to_log_file_when_string_debugging_is_disabled() { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', [ 'request', 'response' ] ); + + AspireUpdate\Debug::log_string( 'Test log message.' ); + + $this->assertFileDoesNotExist( self::$log_file ); + } + + /** + * Test that the message is written to the log file. + * + * @dataProvider data_debug_types + * + * @param array $debug_types An array of enabled debug types. + */ + public function test_should_write_to_log_file( $debug_types ) { + define( 'AP_DEBUG', true ); + define( 'AP_DEBUG_TYPES', $debug_types ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log_string( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was created.' + ); + + $this->assertStringContainsString( + $message, + file_get_contents( self::$log_file ), + 'The message was not logged.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_debug_types() { + return [ + 'just "string"' => [ + 'debug_types' => [ 'string' ], + ], + '"string" at the start of the array"' => [ + 'debug_types' => [ 'string', 'request' ], + ], + '"string" in the middle of the array"' => [ + 'debug_types' => [ 'request', 'string', 'response' ], + ], + '"string" at the end of the array"' => [ + 'debug_types' => [ 'request', 'response', 'string' ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/Debug/Debug_LogTest.php b/tests/phpunit/tests/Debug/Debug_LogTest.php new file mode 100644 index 0000000..c95036f --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_LogTest.php @@ -0,0 +1,210 @@ +assertFileDoesNotExist( + self::$log_file, + 'The log file was created.' + ); + } + + /** + * Test that the log file is created when it doesn't already exist. + */ + public function test_should_create_log_file_if_it_does_not_already_exist() { + $this->assertFileDoesNotExist( + self::$log_file, + 'The log file already exists before testing.' + ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + } + + /** + * Test that the message is added to the log file. + * + * @covers \AspireUpdate\Debug::format_message + */ + public function test_should_add_message_to_log_file() { + $this->assertFileDoesNotExist( + self::$log_file, + 'The log file already exists before testing.' + ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + + $this->assertStringContainsString( + $message, + file_get_contents( self::$log_file ), + 'The message was not added.' + ); + } + + /** + * Test that the message is prepended to an existing log file. + * + * @covers \AspireUpdate\Debug::format_message + */ + public function test_should_add_message_to_an_existing_log_file() { + $existing_content = 'An existing log file.'; + file_put_contents( self::$log_file, $existing_content ); + + $message = 'Test log message.'; + + AspireUpdate\Debug::log( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + + $this->assertStringContainsString( + "$message\n$existing_content", + file_get_contents( self::$log_file ), + 'The message was not prepended to the log file.' + ); + } + + /** + * Test that the message is prefixed with the timestamp. + * + * @covers \AspireUpdate\Debug::format_message + */ + public function test_should_prefix_message_with_timestamp() { + AspireUpdate\Debug::log( 'Test log message.' ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + + $this->assertMatchesRegularExpression( + '/^\[[0-9]{4}\-[0-9]{2}\-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\]/', + file_get_contents( self::$log_file ), + 'The message was not prefixed with the timestamp.' + ); + } + + /** + * Test that the message is prefixed with its type. + * + * @dataProvider data_message_types + * + * @covers \AspireUpdate\Debug::format_message + * + * @param string $type The type of message. + */ + public function test_should_prefix_message_with_type( $type ) { + $message = 'Test log message.'; + + AspireUpdate\Debug::log( $message, $type ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + + $this->assertStringContainsString( + '[' . strtoupper( $type ) . ']: ' . $message, + file_get_contents( self::$log_file ), + 'The message was not prefixed with its type.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_message_types() { + return $this->text_array_to_dataprovider( + [ + 'string', + 'request', + 'response', + 'custom', + ] + ); + } + + /** + * Test that array and object messages are expanded. + * + * @dataProvider data_arrays_and_objects + * + * @covers \AspireUpdate\Debug::format_message + * + * @param array|object $message The message. + */ + public function test_should_expand_array_or_object_messages( $message ) { + AspireUpdate\Debug::log( $message ); + + $this->assertFileExists( + self::$log_file, + 'The log file was not created.' + ); + + $this->assertStringContainsString( + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + print_r( $message, true ), + file_get_contents( self::$log_file ), + 'The array message was not expanded.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_arrays_and_objects() { + return [ + 'an array' => [ + 'message' => [], + ], + 'a non-empty array' => [ + 'message' => [ 'First line', 'Second line', 'Third line' ], + ], + 'an object with no properties' => [ + 'message' => (object) [], + ], + 'an object with properties' => [ + 'message' => (object) [ 'First line', 'Second line', 'Third line' ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/Debug/Debug_ReadTest.php b/tests/phpunit/tests/Debug/Debug_ReadTest.php new file mode 100644 index 0000000..5f275e1 --- /dev/null +++ b/tests/phpunit/tests/Debug/Debug_ReadTest.php @@ -0,0 +1,187 @@ +assertWPError( AspireUpdate\Debug::read() ); + } + + /** + * Test that a WP_Error object is returned when the log file doesn't exist. + * + * @covers \AspireUpdate\Debug::init_filesystem + * @covers \AspireUpdate\Debug::verify_filesystem + * @covers \AspireUpdate\Debug::get_file_path + */ + public function test_should_return_wp_error_when_log_file_does_not_exist() { + $this->assertWPError( AspireUpdate\Debug::read() ); + } + + /** + * Test that a WP_Error object is returned when the log file isn't readable. + * + * @covers \AspireUpdate\Debug::init_filesystem + * @covers \AspireUpdate\Debug::verify_filesystem + * @covers \AspireUpdate\Debug::get_file_path + */ + public function test_should_return_wp_error_when_log_file_is_not_readable() { + global $wp_filesystem; + + // Create the log file. + file_put_contents( self::$log_file, '' ); + + // Backup and replace the filesystem object. + $wp_filesystem = $this->get_fake_filesystem( true, false, true ); + + $actual = AspireUpdate\Debug::read(); + + $this->assertWPError( $actual ); + } + + /** + * Test that an empty log message is returned when the log file is empty. + */ + public function test_should_return_an_empty_log_message_when_log_file_is_empty() { + file_put_contents( self::$log_file, '' ); + + $actual = AspireUpdate\Debug::read(); + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringContainsString( + 'Log file is empty', + $actual, + 'The empty log file message was not returned.' + ); + } + + /** + * Test that an empty log message is returned when the log file's content + * is only empty space. + */ + public function test_should_return_an_empty_log_message_when_log_file_only_has_empty_space() { + file_put_contents( self::$log_file, " \n\r\t\v\x00" ); + + $actual = AspireUpdate\Debug::read(); + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringContainsString( + 'Log file is empty', + $actual, + 'The empty log file message was not returned.' + ); + } + + /** + * Test that an empty log message is not returned when the log file + * has contents. + */ + public function test_should_not_return_an_empty_log_message_when_log_file_has_contents() { + file_put_contents( self::$log_file, 'Some contents' ); + + $actual = AspireUpdate\Debug::read(); + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringNotContainsString( + 'Log file is empty', + $actual, + 'The empty log file message was returned.' + ); + } + + /** + * Test that a truncation message is added when the log file has more + * lines than requested. + */ + public function test_should_add_a_truncation_message_when_log_file_has_more_lines_than_requested() { + file_put_contents( + self::$log_file, + "First line\r\nSecond line\r\nThird line" + ); + + $actual = AspireUpdate\Debug::read( 2 ); + + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringContainsString( + 'Log truncated', + $actual, + 'The truncation message was not returned.' + ); + } + + /** + * Test that no truncation message is added when the log file has the same + * number of lines as requested. + */ + public function test_should_not_add_a_truncation_message_when_log_file_has_the_same_number_of_lines_as_requested() { + file_put_contents( + self::$log_file, + "First line\r\nSecond line\r\nThird line" + ); + + $actual = AspireUpdate\Debug::read( 3 ); + + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringNotContainsString( + 'Log truncated', + $actual, + 'The truncation message was added.' + ); + } + + /** + * Test that no truncation message is added when the log file has fewer than + * lines than requested. + */ + public function test_should_not_add_a_truncation_message_when_log_file_has_fewer_lines_than_requested() { + file_put_contents( + self::$log_file, + "First line\r\nSecond line\r\nThird line" + ); + + $actual = AspireUpdate\Debug::read( 4 ); + + $this->assertIsString( + $actual, + 'A string was not returned.' + ); + + $this->assertStringNotContainsString( + 'Log truncated', + $actual, + 'The truncation message was added.' + ); + } +}