diff --git a/inc/packages/class-releasedocument.php b/inc/packages/class-releasedocument.php index f4fd1058..3840de83 100644 --- a/inc/packages/class-releasedocument.php +++ b/inc/packages/class-releasedocument.php @@ -95,6 +95,15 @@ public static function from_data( stdClass $data ) { } } + // Normalize to arrays of artifact objects since spec supports both formats. + if ( $doc->artifacts instanceof stdClass ) { + foreach ( get_object_vars( $doc->artifacts ) as $type => $artifact ) { + if ( is_object( $artifact ) ) { + $doc->artifacts->{$type} = [ $artifact ]; + } + } + } + return $doc; } } diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php index 28ee8886..b3a83a53 100644 --- a/inc/packages/namespace.php +++ b/inc/packages/namespace.php @@ -460,15 +460,19 @@ function pick_artifact_by_lang( array $artifacts, ?string $locale = null ) { // Score artifacts based on match. $score_artifact = function ( $artifact ) use ( $langs ) { $score = 0; + $lang = strtolower( $artifact->lang ?? '' ); // Check for lang match. - $idx = array_search( strtolower( $artifact->lang ), $langs, true ); + $idx = array_search( $lang, $langs, true ); if ( $idx !== false ) { $score += ( count( $langs ) - $idx ) * 100; } return $score; }; + + // Minimal behavior only: missing lang is tolerated, but equal-score ordering + // and partial-locale fallback precedence are intentionally unspecified. usort( $artifacts, function ( $a, $b ) use ( $score_artifact ) { $a_score = $score_artifact( $a ); $b_score = $score_artifact( $b ); @@ -726,6 +730,7 @@ function get_package_data( $did ) { $type = str_replace( 'wp-', '', $metadata->type ); $sections = (array) $metadata->sections; $description = trim( $sections['description'] ?? '' ); + $package_artifact = isset( $release->artifacts->package ) ? pick_artifact_by_lang( $release->artifacts->package ) : null; $response = [ 'name' => $metadata->name, @@ -747,8 +752,8 @@ function get_package_data( $did ) { 'new_version' => $release->version, 'version' => $release->version, 'remote_version' => $release->version, - 'package' => $release->artifacts->package[0]->url, - 'download_link' => $release->artifacts->package[0]->url, + 'package' => $package_artifact->url ?? '', + 'download_link' => $package_artifact->url ?? '', 'tested' => $required_versions['tested_to'] ?? '', 'external' => 'xxx', 'last_updated' => $metadata->last_updated ?? '', @@ -791,10 +796,7 @@ function cache_did_for_install( array $options ): array { $did = array_find_key( $releases, function ( $release ) use ( $options ) { - if ( ! is_array( $release->artifacts->package ) ) { - return false; - } - $artifact = pick_artifact_by_lang( $release->artifacts->package ); + $artifact = isset( $release->artifacts->package ) ? pick_artifact_by_lang( $release->artifacts->package ) : null; return $artifact && $artifact->url === $options['package']; } ); @@ -964,8 +966,12 @@ function maybe_add_accept_header( $args, $url ) : array { } foreach ( $releases as $release ) { - if ( $url === $release->artifacts->package[0]->url ) { - $content_type = $release->artifacts->package[0]->{'content-type'}; + $artifact = array_find( + $release->artifacts->package ?? [], + fn ( $package_artifact ) => $url === ( $package_artifact->url ?? '' ) + ); + if ( $artifact ) { + $content_type = $artifact->{'content-type'} ?? ''; if ( $content_type === 'application/octet-stream' ) { $args = array_merge( $args, [ 'headers' => [ 'Accept' => $content_type ] ] ); break; diff --git a/tests/phpunit/tests/Packages/PickArtifactByLangTest.php b/tests/phpunit/tests/Packages/PickArtifactByLangTest.php new file mode 100644 index 00000000..cd44e4ae --- /dev/null +++ b/tests/phpunit/tests/Packages/PickArtifactByLangTest.php @@ -0,0 +1,53 @@ + 'https://example.com/no-lang.zip', + ]; + $matching_artifact = (object) [ + 'url' => 'https://example.com/de-de.zip', + 'lang' => 'de-DE', + ]; + + $selected = pick_artifact_by_lang( [ $fallback_artifact, $matching_artifact ], 'de-DE' ); + + $this->assertSame( $matching_artifact, $selected, 'The exact locale match should be selected.' ); + } + + /** + * Test should not fail when artifacts do not specify lang. + */ + public function test_should_return_an_artifact_when_lang_is_missing() { + $first_artifact = (object) [ + 'url' => 'https://example.com/first.zip', + ]; + $second_artifact = (object) [ + 'url' => 'https://example.com/second.zip', + ]; + + $selected = pick_artifact_by_lang( [ $first_artifact, $second_artifact ], 'de-DE' ); + + $this->assertContains( $selected, [ $first_artifact, $second_artifact ], 'Artifacts without lang should still return a valid artifact.' ); + } +} diff --git a/tests/phpunit/tests/Packages/ReleaseDocumentTest.php b/tests/phpunit/tests/Packages/ReleaseDocumentTest.php new file mode 100644 index 00000000..85952b5a --- /dev/null +++ b/tests/phpunit/tests/Packages/ReleaseDocumentTest.php @@ -0,0 +1,78 @@ + 'https://example.com/plugin.zip', + ]; + $icon_artifact = (object) [ + 'url' => 'https://example.com/icon.png', + ]; + $custom_artifact = (object) [ + 'url' => 'https://example.com/extra.json', + ]; + + $release = ReleaseDocument::from_data( + (object) [ + 'version' => '1.2.3', + 'artifacts' => (object) [ + 'package' => $package_artifact, + 'icon' => $icon_artifact, + 'x-extra' => $custom_artifact, + ], + ] + ); + + $this->assertNotWPError( $release, 'Expected a valid release document.' ); + $this->assertIsArray( $release->artifacts->package, 'Package artifacts should be normalized to an array.' ); + $this->assertIsArray( $release->artifacts->icon, 'Icon artifacts should be normalized to an array.' ); + $this->assertIsArray( $release->artifacts->{'x-extra'}, 'Custom artifact types should be normalized to an array.' ); + $this->assertSame( $package_artifact, $release->artifacts->package[0], 'The original package artifact should be preserved.' ); + $this->assertSame( $icon_artifact, $release->artifacts->icon[0], 'The original icon artifact should be preserved.' ); + $this->assertSame( $custom_artifact, $release->artifacts->{'x-extra'}[0], 'The original custom artifact should be preserved.' ); + } + + /** + * Test should preserve artifact arrays. + */ + public function test_should_preserve_artifact_arrays() { + $package_artifact = (object) [ + 'url' => 'https://example.com/plugin.zip', + ]; + $banner_artifact = (object) [ + 'url' => 'https://example.com/banner.png', + ]; + + $release = ReleaseDocument::from_data( + (object) [ + 'version' => '1.2.3', + 'artifacts' => (object) [ + 'package' => [ $package_artifact ], + 'banner' => [ $banner_artifact ], + ], + ] + ); + + $this->assertNotWPError( $release, 'Expected a valid release document.' ); + $this->assertCount( 1, $release->artifacts->package, 'Package artifact arrays should be preserved.' ); + $this->assertCount( 1, $release->artifacts->banner, 'Banner artifact arrays should be preserved.' ); + $this->assertSame( $package_artifact, $release->artifacts->package[0], 'Existing package array entries should be preserved.' ); + $this->assertSame( $banner_artifact, $release->artifacts->banner[0], 'Existing banner array entries should be preserved.' ); + } +}