diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index be228a1885..74c65fe541 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -79,6 +79,11 @@ jobs: wp-env-args: "--config plugin-directory/.wp-env.test.json" container: tests-cli plugin-name: plugin-directory + - name: Theme Directory + working-directory: environments + wp-env-args: "--config theme-directory/.wp-env.test.json" + container: tests-cli + plugin-name: theme-directory steps: - uses: actions/checkout@v4 diff --git a/environments/theme-directory/.wp-env.test.json b/environments/theme-directory/.wp-env.test.json new file mode 100644 index 0000000000..1d7f9ca999 --- /dev/null +++ b/environments/theme-directory/.wp-env.test.json @@ -0,0 +1,10 @@ +{ + "core": "WordPress/WordPress#master", + "phpVersion": "8.4", + "plugins": [ + "../wordpress.org/public_html/wp-content/plugins/theme-directory" + ], + "lifecycleScripts": { + "afterStart": "bash theme-directory/bin/after-start-test.sh" + } +} diff --git a/environments/theme-directory/bin/after-start-test.sh b/environments/theme-directory/bin/after-start-test.sh new file mode 100755 index 0000000000..889be08e46 --- /dev/null +++ b/environments/theme-directory/bin/after-start-test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# +# Runs after wp-env start for the test environment. +# Installs PHPUnit 11 and Yoast polyfills in the test container. +# + +CONFIG="--config theme-directory/.wp-env.test.json" +RUN="npx wp-env $CONFIG run tests-cli" + +echo "Installing PHPUnit 11 and polyfills..." +$RUN composer global require -W phpunit/phpunit:^11.0 2>&1 +$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1 diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php index 2a0f36b6c6..52ccaa37a4 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php @@ -866,7 +866,8 @@ public function fill_theme( $theme ) { if ( $this->fields['versions'] ) { $phil->versions = array(); - foreach ( array_keys( get_post_meta( $theme->ID, '_status', true ) ) as $version ) { + $status = get_post_meta( $theme->ID, '_status', true ); + foreach ( is_array( $status ) ? array_keys( $status ) : array() as $version ) { $phil->versions[ $version ] = $repo_package->download_url( $version ); } } diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php index 7e9fdd3b7d..5526d4d553 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php @@ -787,7 +787,7 @@ protected function import( $args = array() ) { } if ( $is_new_upload && $this->theme_post ) { - wp_delete_post( $this->theme_post->ID, true ); + $this->delete_theme_post(); } return new WP_Error( @@ -1315,7 +1315,7 @@ public function create_or_update_trac_ticket() { * the superseded version is simply demoted to `old`. */ if ( in_array( $prev_status, [ 'new', 'approved' ], true ) ) { - $ticket_id = (int) $this->theme_post->_ticket_id[ $this->theme_post->max_version ]; + $ticket_id = (int) ( $this->theme_post->_ticket_id[ $this->theme_post->max_version ] ?? 0 ); $ticket = $this->trac->ticket_get( $ticket_id ); // Make sure the ticket has not yet been resolved. @@ -1449,6 +1449,24 @@ public function create_or_update_theme_post() { } } + /** + * Deletes the theme post. + * + * Used to clean up a freshly created post when a new upload fails. + * Temporarily detaches the fail-safe that prevents repopackages from + * being deleted, which would otherwise wp_die() before the post is + * removed, leaving an orphaned post without versioned meta behind. + * + * @return WP_Post|false|null Post data on success, false or null on failure. + */ + public function delete_theme_post() { + remove_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + $result = wp_delete_post( $this->theme_post->ID, true ); + add_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + + return $result; + } + /** * Add theme files to SVN. * @@ -1752,7 +1770,7 @@ public function get_all_files( $dir ) { * @return WP_Theme */ public function populate_post_with_meta( $theme ) { - foreach ( get_post_custom_keys( $theme->ID ) as $meta_key ) { + foreach ( (array) get_post_custom_keys( $theme->ID ) as $meta_key ) { $theme->$meta_key = get_post_meta( $theme->ID, $meta_key, true ); if ( is_array( $theme->$meta_key ) ) { @@ -1761,7 +1779,7 @@ public function populate_post_with_meta( $theme ) { } // Save the highest recorded version number. - $uploaded_versions = array_keys( $theme->_status ); + $uploaded_versions = is_array( $theme->_status ) ? array_keys( $theme->_status ) : array(); $theme->max_version = end( $uploaded_versions ); return $theme; diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml new file mode 100644 index 0000000000..71b6007751 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml @@ -0,0 +1,12 @@ + + + + tests/ + tests/bootstrap.php + + + diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php new file mode 100644 index 0000000000..a5e20261dc --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php @@ -0,0 +1,185 @@ +post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + add_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + + $this->post_ids = array(); + + parent::tearDown(); + } + + /** + * Creates a repopackage post. + * + * @param array $meta Optional. Post meta to add to the post. + * @return WP_Post The created post. + */ + protected function create_repopackage( $meta = array() ) { + $post_id = wp_insert_post( array( + 'post_type' => 'repopackage', + 'post_status' => 'publish', + 'post_title' => 'Test Theme', + 'post_name' => 'test-theme', + 'post_author' => 1, + ) ); + + $this->post_ids[] = $post_id; + + foreach ( $meta as $key => $value ) { + add_post_meta( $post_id, $key, $value ); + } + + return get_post( $post_id ); + } + + /** + * A post with no meta at all should not fatal and yield no max_version. + */ + public function test_populate_post_with_meta_without_any_meta() { + $post = $this->create_repopackage(); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertFalse( $theme->max_version ); + } + + /** + * A post with versioned meta but no `_status` (an orphan from a failed + * import) should not fatal and yield no max_version. + */ + public function test_populate_post_with_meta_without_status_meta() { + $post = $this->create_repopackage( array( + '_requires' => array( '1.0.0' => '5.0' ), + '_author' => array( '1.0.0' => 'Test Author' ), + ) ); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertFalse( $theme->max_version ); + $this->assertSame( array( '1.0.0' => '5.0' ), $theme->_requires ); + } + + /** + * A post with valid `_status` meta should yield the highest version. + */ + public function test_populate_post_with_meta_with_status_meta() { + $post = $this->create_repopackage( array( + '_status' => array( + '1.1' => 'old', + '1.0' => 'old', + '1.10' => 'live', + ), + ) ); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertSame( '1.10', $theme->max_version ); + } + + /** + * Cleaning up a failed new upload should delete the theme post despite + * the fail-safe against repopackage deletion, and restore the fail-safe + * afterwards. + */ + public function test_delete_theme_post_bypasses_deletion_fail_safe() { + $post = $this->create_repopackage(); + $upload = new WPORG_Themes_Upload(); + + $upload->theme_post = $post; + $result = $upload->delete_theme_post(); + + $this->assertNotEmpty( $result ); + $this->assertNull( get_post( $post->ID ) ); + $this->assertSame( 10, has_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ) ); + } + + /** + * The themes API should return an empty `versions` array for a published + * theme missing the `_status` meta, rather than fataling. + */ + public function test_theme_information_versions_without_status_meta() { + $this->create_repopackage(); + + $api = new Themes_API( + 'theme_information', + array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) + ); + + $this->assertObjectNotHasProperty( 'error', $api->response ); + $this->assertSame( array(), $api->response->versions ); + } + + /** + * The themes API should return all versions for a theme with valid + * `_status` meta. + */ + public function test_theme_information_versions_with_status_meta() { + $this->create_repopackage( array( + '_status' => array( + '1.0' => 'old', + '1.1' => 'live', + ), + ) ); + + $api = new Themes_API( + 'theme_information', + array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) + ); + + $this->assertObjectNotHasProperty( 'error', $api->response ); + $this->assertSame( array( '1.0', '1.1' ), array_keys( $api->response->versions ) ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php new file mode 100644 index 0000000000..e232641bc1 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php @@ -0,0 +1,60 @@ +