From 8629cdb38b2fab5c0d0654d55062c6b0982babb0 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Fri, 5 Jun 2026 18:12:18 +0100 Subject: [PATCH 1/8] wip --- README.md | 56 +---- src/MarkClosedPluginAsAbandoned.php | 42 +--- src/Plugin.php | 42 +--- src/WpOrg/Api/ArrayCache.php | 21 -- src/WpOrg/Api/CacheInterface.php | 12 - src/WpOrg/Api/CacheProxy.php | 34 --- src/WpOrg/Api/Client.php | 136 ----------- src/WpOrg/Api/FileCache.php | 63 ------ src/WpPackages/Api/Client.php | 114 ++++++++++ tests/Feature/WpOrg/Api/ClientTest.php | 93 -------- tests/Feature/WpPackages/Api/ClientTest.php | 236 ++++++++++++++++++++ tests/Unit/WpOrg/Api/ArrayCacheTest.php | 61 ----- tests/Unit/WpOrg/Api/CacheProxyTest.php | 88 -------- tests/Unit/WpOrg/Api/FileCacheTest.php | 118 ---------- 14 files changed, 371 insertions(+), 745 deletions(-) delete mode 100644 src/WpOrg/Api/ArrayCache.php delete mode 100644 src/WpOrg/Api/CacheInterface.php delete mode 100644 src/WpOrg/Api/CacheProxy.php delete mode 100644 src/WpOrg/Api/Client.php delete mode 100644 src/WpOrg/Api/FileCache.php create mode 100644 src/WpPackages/Api/Client.php delete mode 100644 tests/Feature/WpOrg/Api/ClientTest.php create mode 100644 tests/Feature/WpPackages/Api/ClientTest.php delete mode 100644 tests/Unit/WpOrg/Api/ArrayCacheTest.php delete mode 100644 tests/Unit/WpOrg/Api/CacheProxyTest.php delete mode 100644 tests/Unit/WpOrg/Api/FileCacheTest.php diff --git a/README.md b/README.md index 47d63ca..86026a1 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,10 @@ Package wpackagist-plugin/my-closed-plugin is abandoned because https://wordpres ## Why -When a plugin is closed on WordPress.org, [WPackagist](https://wpackagist.org/) not always remove it from its database immediately. -As a result, some closed plugins remain available for installation via WPackagist. +When a plugin is closed on WordPress.org, Composer repositories (e.g: [WPackagist](https://wpackagist.org/) & [WP Packages](https://wp-packages.org/) not always remove it from its database immediately. +Worse still, even after the Composer repositories removed it., a closed plugin might persists in `composer.lock` with references to download.wordPress.org endpoint and [WordPress plugin subversion repository](https://plugins.svn.wordpress.org/) +As a result, closed plugins remain installable. -Moreover, even if a plugin is closed, its existing versions are still downloadable from WordPress.org and the subversion repository. ```json { "repositories": [ @@ -87,7 +87,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab "version": "1.0", "source": { "type": "svn", - "url": "https://plugins.svn.wordpress.org/my-closed-plugin/", + "url": "https://plugins.svn.wordpress.org/my-closed-plugin/", // subversion repository "reference": "tags/1.0" } } @@ -99,7 +99,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab "version": "1.0", "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/your-closed-plugin.1.0.zip" + "url": "https://downloads.wordpress.org/plugin/your-closed-plugin.1.0.zip" // download.wordPress.org endpoint } } } @@ -107,7 +107,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab } ``` -To catch these closed plugins, `WP Org Closed Plugin` queries [WordPress.org API](https://codex.wordpress.org/WordPress.org_API#Plugins) to check whether a plugin is closed and mark them as abandoned in Composer. +To catch these closed plugins, `WP Org Closed Plugin` queries [WP Packages API](https://wp-packages.org/api/packages/wp-plugin/closed) for the list of plugins closed on WordPress.org and marks them as abandoned in Composer. ## What to do when a plugin is closed? @@ -151,50 +151,6 @@ Skipped checking for closed plugins because of --locked. You should run `composer audit` without `--locked` to check for closed plugins. -### Cache - -WordPress.org API responses are cached for 10 minutes. - -If you must clear the cache, delete the `/wp-org-closed-plugin` directory. - -```sh -rm -rf $(composer config cache-dir)/wp-org-closed-plugin -``` - -### Why `allow_self_signed` when connecting to `https://api.wordpress.org`? - -> [!IMPORTANT] -> **Help Wanted!** -> -> Please send pull requests if you know how to get around the error: -> -> ```console -> $ curl --http3-only 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -> curl: (56) ngtcp2_conn_writev_stream returned error: ERR_DRAINING -> ``` - -It is a hack to disallow HTTP/3, forcing `HttpDownloader` to use `RemoteFilesystem` instead of `CurlDownloader`. - -I suspect api.wordpress.org does not properly support HTTP/3: - -```console -$ curl --http1.1 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...json response - -$ curl --http2 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...json response - -$ curl --http3-only 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...sometimes json response -...but most of the time ERR_DRAINING -curl: (56) ngtcp2_conn_writev_stream returned error: ERR_DRAINING -``` - -See: -- https://github.com/composer/composer/pull/12363 -- https://github.com/composer/composer/blob/f5854b140ca27164d352ce30deece798acf3e36b/src/Composer/Util/HttpDownloader.php#L537 -- https://github.com/typisttech/wp-org-closed-plugin/pull/22 - > [!TIP] > **Hire Tang Rufus!** > diff --git a/src/MarkClosedPluginAsAbandoned.php b/src/MarkClosedPluginAsAbandoned.php index a6a3093..c63a8d1 100644 --- a/src/MarkClosedPluginAsAbandoned.php +++ b/src/MarkClosedPluginAsAbandoned.php @@ -4,33 +4,24 @@ namespace TypistTech\WpOrgClosedPlugin; -use Composer\Cache; +use Composer\Cache as ComposerCache; use Composer\Composer; use Composer\IO\IOInterface; use Composer\Package\CompletePackageInterface; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\ArrayCache; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\CacheProxy; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Client; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\FileCache; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\DownloadUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\MultiUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\SvnUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\UrlParserInterface; +use TypistTech\WpOrgClosedPlugin\WpPackages\Api\Client; readonly class MarkClosedPluginAsAbandoned { public static function create(Composer $composer, IOInterface $io): self { - $loop = $composer->getLoop(); - $config = $composer->getConfig(); $cachePath = "{$config->get('cache-dir')}/wp-org-closed-plugin"; - $composerCache = new Cache($io, $cachePath); + $composerCache = new ComposerCache($io, $cachePath); $composerCache->setReadOnly($config->get('cache-read-only')); - $cache = new CacheProxy( - new ArrayCache, - new FileCache($composerCache), - ); return new self( new MultiUrlParser( @@ -38,9 +29,8 @@ public static function create(Composer $composer, IOInterface $io): self new SvnUrlParser, ), new Client( - $loop->getHttpDownloader(), - $loop, - $cache, + $composer->getLoop()->getHttpDownloader(), + $composerCache, ), $io, ); @@ -52,28 +42,6 @@ public function __construct( private IOInterface $io, ) {} - public function warmCache(CompletePackageInterface ...$packages): void - { - $slugs = array_map( - fn (CompletePackageInterface $package): ?string => $this->slug( - ...$package->getDistUrls(), - ...$package->getSourceUrls(), - ), - $packages, - ); - $slugs = array_filter($slugs, static fn (?string $slug) => $slug !== null); - - $slugCount = count($slugs); - $message = sprintf( - 'Warming WordPress.org plugin status cache for %d %s', - $slugCount, - $slugCount === 1 ? 'slug' : 'slugs', - ); - $this->io->debug($message); - - $this->client->warmCache(...$slugs); - } - public function __invoke(CompletePackageInterface $package): void { $this->io->debug( diff --git a/src/Plugin.php b/src/Plugin.php index 9eb05e7..b2a226a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -48,14 +48,8 @@ public function uninstall(Composer $composer, IOInterface $io): void public static function getSubscribedEvents(): array { return [ - PackageEvents::PRE_PACKAGE_INSTALL => [ - ['warmCache', PHP_INT_MAX - 500], - ['markClosedAsAbandoned', PHP_INT_MAX - 1000], - ], - PackageEvents::PRE_PACKAGE_UPDATE => [ - ['warmCache', PHP_INT_MAX - 500], - ['markClosedAsAbandoned', PHP_INT_MAX - 1000], - ], + PackageEvents::PRE_PACKAGE_INSTALL => ['markClosedAsAbandoned', PHP_INT_MAX - 1000], + PackageEvents::PRE_PACKAGE_UPDATE => ['markClosedAsAbandoned', PHP_INT_MAX - 1000], ScriptEvents::POST_INSTALL_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000], ScriptEvents::POST_UPDATE_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000], @@ -64,22 +58,6 @@ public static function getSubscribedEvents(): array ]; } - public function warmCache(PackageEvent $event): void - { - $packages = array_map( - static fn ($operation) => match (true) { - $operation instanceof InstallOperation => $operation->getPackage(), - $operation instanceof UpdateOperation => $operation->getTargetPackage(), - default => null, - }, - $event->getOperations(), - ); - $packages = array_filter($packages, static fn ($package) => $package instanceof CompletePackageInterface); - $packages = array_filter($packages, static fn ($package) => ! $package->isAbandoned()); - - $this->markClosedAsAbandoned->warmCache(...$packages); - } - public function markClosedAsAbandoned(PackageEvent $event): void { $operation = $event->getOperation(); @@ -109,18 +87,18 @@ public function markClosedLockedPackagesIfNotAlready(ScriptEvent $event): void ->getLocker() ->getLockedRepository(true); - $packages = $lockedRepository->getPackages(); - $packages = array_filter($packages, static fn ($package) => $package instanceof CompletePackage); - $packages = array_filter($packages, static fn ($package) => ! $package->isAbandoned()); $packages = array_filter( - $packages, - fn ($package) => ! in_array($package->getPrettyName(), $this->marked, true), + $lockedRepository->getPackages(), + static fn ($package) => $package instanceof CompletePackage && ! $package->isAbandoned(), ); - $this->markClosedAsAbandoned->warmCache(...$packages); - foreach ($packages as $package) { - $this->marked[] = $package->getPrettyName(); + $prettyName = $package->getPrettyName(); + if (in_array($prettyName, $this->marked, true)) { + continue; + } + + $this->marked[] = $prettyName; $this->markClosedAsAbandoned->__invoke($package); } } diff --git a/src/WpOrg/Api/ArrayCache.php b/src/WpOrg/Api/ArrayCache.php deleted file mode 100644 index 6c23cd4..0000000 --- a/src/WpOrg/Api/ArrayCache.php +++ /dev/null @@ -1,21 +0,0 @@ - */ - private array $data = []; - - public function read(string $slug): ?bool - { - return $this->data[$slug] ?? null; - } - - public function write(string $slug, bool $isClosed): void - { - $this->data[$slug] = $isClosed; - } -} diff --git a/src/WpOrg/Api/CacheInterface.php b/src/WpOrg/Api/CacheInterface.php deleted file mode 100644 index e80b782..0000000 --- a/src/WpOrg/Api/CacheInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -fast->read($slug); - if ($result !== null) { - return $result; - } - - $result = $this->slow->read($slug); - if ($result !== null) { - $this->fast->write($slug, $result); - } - - return $result; - } - - public function write(string $slug, bool $isClosed): void - { - $this->fast->write($slug, $isClosed); - $this->slow->write($slug, $isClosed); - } -} diff --git a/src/WpOrg/Api/Client.php b/src/WpOrg/Api/Client.php deleted file mode 100644 index a1084f3..0000000 --- a/src/WpOrg/Api/Client.php +++ /dev/null @@ -1,136 +0,0 @@ - [ - 'allow_self_signed' => true, - ], - ]; - - public function __construct( - private HttpDownloader $httpDownloader, - private Loop $loop, - private CacheInterface $cache, - ) {} - - public function warmCache(string ...$slugs): void - { - $slugs = array_map('trim', $slugs); - $slugs = array_filter($slugs, static fn (string $slug) => $slug !== ''); - - $promises = array_map( - fn (string $slug) => $this->isClosedAsync($slug), - $slugs, - ); - - $this->loop->wait($promises); - } - - public function isClosed(string $slug): bool - { - $slug = trim($slug); - if ($slug === '') { - return false; - } - - $result = null; - $promise = $this->isClosedAsync($slug) - ->then(function (bool $isClosed) use (&$result): void { - $result = $isClosed; - }); - $this->loop->wait([$promise]); - - /** @var bool */ - return $result; - } - - /** - * @return PromiseInterface - */ - private function isClosedAsync(string $slug): PromiseInterface - { - /** @var Promise */ - return new Promise(function (callable $resolve) use ($slug): void { - $cached = $this->cache->read($slug); - $next = $cached ?? $this->fetchAndCacheAsync($slug); - $resolve($next); - }); - } - - /** - * @return PromiseInterface - */ - private function fetchAndCacheAsync(string $slug): PromiseInterface - { - return $this->fetchAsync($slug) - ->then(function (bool $isClosed) use ($slug): bool { - $this->cache->write($slug, $isClosed); - - return $isClosed; - }); - } - - /** - * @return PromiseInterface - */ - private function fetchAsync(string $slug): PromiseInterface - { - $url = sprintf( - 'https://api.wordpress.org/plugins/info/1.2/?%s', - http_build_query([ - 'action' => 'plugin_information', - 'slug' => $slug, - ], '', '&'), - ); - - return $this->httpDownloader->add($url, self::OPTIONS) - ->then(static fn () => null) // Ignore successful responses. Closed plugins always return 404. - ->catch(static function (TransportException $e): ?string { - // Closed plugins always return 404. - return $e->getStatusCode() === 404 - ? $e->getResponse() - : null; - })->then(static function (?string $body): bool { - if ($body === null) { - return false; - } - - /** @var array{error?: string} $json */ - $json = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $error = $json['error'] ?? null; - - return $error === 'closed'; - })->catch(static fn () => false); - } -} diff --git a/src/WpOrg/Api/FileCache.php b/src/WpOrg/Api/FileCache.php deleted file mode 100644 index 05841f6..0000000 --- a/src/WpOrg/Api/FileCache.php +++ /dev/null @@ -1,63 +0,0 @@ -key($slug); - $age = $this->cache->getAge($key); - - // Missed or expired. - if ($age === false || $age > self::TTL) { - return null; - } - - $content = (string) $this->cache->read($key); - - return match (true) { - str_starts_with($content, 'closed'.PHP_EOL) => true, - str_starts_with($content, 'open'.PHP_EOL) => false, - default => null, // Unexpected content. Treat as a miss. - }; - } - - public function write(string $slug, bool $isClosed): void - { - if ($this->cache->isReadOnly()) { - return; - } - - $content = sprintf( - '%s%s%s%s%s%s', - $isClosed ? 'closed' : 'open', - PHP_EOL, - $slug, - PHP_EOL, - date(DateTimeInterface::RFC3339), - PHP_EOL - ); - - $this->cache->write( - $this->key($slug), - $content, - ); - } - - private function key(string $slug): string - { - return "{$slug}.txt"; - } -} diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php new file mode 100644 index 0000000..bd82d1b --- /dev/null +++ b/src/WpPackages/Api/Client.php @@ -0,0 +1,114 @@ +|null */ + private ?array $closedSlugs = null; + + public function __construct( + private readonly HttpDownloader $httpDownloader, + private readonly Cache $cache, + ) {} + + public function isClosed(string $slug): bool + { + $slug = trim($slug); + + return $slug !== '' && isset($this->closedSlugs()[$slug]); + } + + /** + * The closed slugs as a set keyed by slug, resolved at most once per run. + * + * @return array + */ + private function closedSlugs(): array + { + return $this->closedSlugs ??= $this->load(); + } + + /** + * Revalidate the cached list against the endpoint and return the closed set. + * + * A `304 Not Modified`, a transport failure, or an empty/malformed body all + * fall back to the cached copy instead of discarding known data. + * + * @return array + */ + private function load(): array + { + $cached = $this->cache->read(self::CACHE_KEY); + $fallback = $this->decode($cached) ?? []; + + try { + $response = $this->httpDownloader->get(self::URL, $this->options()); + } catch (TransportException) { + return $fallback; + } + + $body = $response->getStatusCode() === 304 ? null : $response->getBody(); + if ($body === null) { + return $fallback; + } + + $fresh = $this->decode($body); + if ($fresh === null) { + return $fallback; + } + + $this->cache->write(self::CACHE_KEY, $body); + + return $fresh; + } + + /** + * A conditional request whenever something is cached, so an unchanged list + * returns a cheap `304 Not Modified` (Composer even synthesises one under + * `COMPOSER_DISABLE_NETWORK`). The marker is the cache entry's own age. + * + * @return array{http?: array{header: list}} + */ + private function options(): array + { + $age = $this->cache->getAge(self::CACHE_KEY); + if ($age === false) { + return []; + } + + $since = gmdate('D, d M Y H:i:s', time() - $age) . ' GMT'; + + return ['http' => ['header' => ['If-Modified-Since: ' . $since]]]; + } + + /** + * Decode the endpoint's JSON array of slugs into a set keyed by slug, or + * null when the payload is not a JSON array so the caller can fall back. + * + * @return array|null + */ + private function decode(string|false $body): ?array + { + $slugs = $body === false ? null : json_decode($body, true); + if (! is_array($slugs) || ! array_is_list($slugs)) { + return null; + } + + return array_fill_keys(array_filter($slugs, is_string(...)), true); + } +} diff --git a/tests/Feature/WpOrg/Api/ClientTest.php b/tests/Feature/WpOrg/Api/ClientTest.php deleted file mode 100644 index f40a89c..0000000 --- a/tests/Feature/WpOrg/Api/ClientTest.php +++ /dev/null @@ -1,93 +0,0 @@ - ['paid-memberships-pro', true], - 'author_request_permanent_2' => ['be-media-from-production', true], - 'guideline_violation' => ['text-control', true], - 'guideline_violation_permanent' => ['no-longer-in-directory', true], - 'licensing-trademark-violation' => ['tiutiu-facebook-friends-widget', true], - 'security_issue' => ['better-delete-revision', true], - 'security_issue_2' => ['browser-bookmark', true], - 'unknown' => ['rumgallery', true], - 'unknown_permanent' => ['link-linker', true], - 'unused' => ['auto-translator', true], - 'unused_permanent' => ['spam-stopgap', true], - 'unused_permanent_2' => ['update-linkroll', true], - - // Not closed. - 'open' => ['hello-dolly', false], - 'not_found' => ['not-found-foo-bar-baz-qux', false], - 'empty_slug' => ['', false], - 'whitespace' => [' ', false], - ]; - }); - - dataset('slugs', static function (): array { - return [ - // Closed. - 'unused_permanent' => ['spam-stopgap', true], - // Not closed. - 'open' => ['hello-dolly', false], - ]; - }); - - it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = $loop->getHttpDownloader(); - $cache = Mockery::spy(CacheInterface::class); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - expect($actual)->toBe($expected); - })->with('many_slugs'); - - it('writes to cache', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = $loop->getHttpDownloader(); - $cache = Mockery::spy(CacheInterface::class); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - $cache->shouldHaveReceived('write', [$slug, $expected]); - expect($actual)->toBe($expected); - })->with('slugs'); - - it('reads from cache', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = Mockery::spy(HttpDownloader::class); - - $cache = Mockery::spy(CacheInterface::class); - $cache->expects() - ->read() - ->with($slug) - ->andReturn($expected); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - $httpDownloader->shouldNotHaveReceived('add'); - $cache->shouldNotHaveReceived('write'); - expect($actual)->toBe($expected); - })->with('slugs'); - }); -}); diff --git a/tests/Feature/WpPackages/Api/ClientTest.php b/tests/Feature/WpPackages/Api/ClientTest.php new file mode 100644 index 0000000..d97b63e --- /dev/null +++ b/tests/Feature/WpPackages/Api/ClientTest.php @@ -0,0 +1,236 @@ + ENDPOINT], $code, [], $body); +} + +describe(Client::class, static function (): void { + describe('::isClosed()', static function (): void { + it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { + $httpDownloader = $this->loop()->getHttpDownloader(); + + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->getAge('closed.json')->andReturnFalse(); + $cache->allows()->write('closed.json', Mockery::any()); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed($slug))->toBe($expected); + })->group('network')->with([ + // Closed. + 'author_request_permanent' => ['paid-memberships-pro', true], + 'guideline_violation_permanent' => ['no-longer-in-directory', true], + 'security_issue' => ['better-delete-revision', true], + 'unused_permanent' => ['spam-stopgap', true], + + // Not closed. + 'open' => ['hello-dolly', false], + 'not_found' => ['not-found-foo-bar-baz-qux', false], + 'empty_slug' => ['', false], + 'whitespace' => [' ', false], + ]); + + it('returns false for an empty or whitespace-only slug without any I/O', function (string $slug): void { + $httpDownloader = Mockery::spy(HttpDownloader::class); + $cache = Mockery::spy(ComposerCache::class); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed($slug))->toBeFalse(); + + $httpDownloader->shouldNotHaveReceived('get'); + $cache->shouldNotHaveReceived('read'); + })->with([ + 'empty' => [''], + 'whitespace' => [' '], + ]); + + it('revalidates with a derived If-Modified-Since and serves the cache on 304', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturn('["spam-stopgap", "better-delete-revision"]'); + $cache->allows()->getAge('closed.json')->andReturn(120); + + $captured = null; + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, Mockery::capture($captured)) + ->once() + ->andReturn(endpointResponse(304, '')); + + $before = time(); + $client = new Client($httpDownloader, $cache); + $isClosed = $client->isClosed('spam-stopgap'); + $isOpen = $client->isClosed('hello-dolly'); + $after = time(); + + expect($isClosed)->toBeTrue() + ->and($isOpen)->toBeFalse(); + + // The marker is derived from time() during the call: accept any + // second the call could have observed to avoid a clock-tick race. + $acceptable = []; + for ($now = $before; $now <= $after; $now++) { + $acceptable[] = 'If-Modified-Since: ' . gmdate('D, d M Y H:i:s', $now - 120) . ' GMT'; + } + expect(array_intersect($captured['http']['header'], $acceptable))->not->toBeEmpty(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('writes the fetched body to cache on a cache miss', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->getAge('closed.json')->andReturnFalse(); + $cache->expects() + ->write('closed.json', '["spam-stopgap"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, []) + ->once() + ->andReturn(endpointResponse(200, '["spam-stopgap"]')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeTrue(); + }); + + it('resolves the closed list at most once', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->expects()->read('closed.json')->once()->andReturnFalse(); + $cache->allows()->getAge('closed.json')->andReturnFalse(); + $cache->allows()->write('closed.json', '["spam-stopgap"]'); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, []) + ->once() + ->andReturn(endpointResponse(200, '["spam-stopgap"]')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse() + ->and($client->isClosed('spam-stopgap'))->toBeTrue(); + }); + + it('fetches a changed list and overwrites the cache', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturn('["spam-stopgap"]'); + $cache->allows()->getAge('closed.json')->andReturn(120); + $cache->expects() + ->write('closed.json', '["better-delete-revision"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, Mockery::any()) + ->andReturn(endpointResponse(200, '["better-delete-revision"]')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue() + ->and($client->isClosed('spam-stopgap'))->toBeFalse(); + }); + + it('keeps using the cached list when the endpoint is unreachable', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge('closed.json')->andReturn(120); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, Mockery::any()) + ->andThrow(new TransportException('Connection refused')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('treats the plugin as not closed when unreachable with an empty cache', function (): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->getAge('closed.json')->andReturnFalse(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, []) + ->andThrow(new TransportException('Connection refused')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeFalse(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('keeps the cached list and skips caching an unusable response', function (?string $body): void { + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge('closed.json')->andReturn(120); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, Mockery::any()) + ->andReturn(endpointResponse(200, $body)); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue(); + + $cache->shouldNotHaveReceived('write'); + })->with([ + 'not json' => ['not-json'], + 'json object' => ['{"error": "better-delete-revision"}'], + 'json string' => ['"better-delete-revision"'], + 'empty body' => [''], + 'null body' => [null], + ]); + + it('caches a legitimately empty closed list', function (): void { + // A valid but empty `[]` is distinct from a malformed body: it is + // cached and returned, rather than falling back. + $cache = Mockery::mock(ComposerCache::class); + $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->getAge('closed.json')->andReturnFalse(); + $cache->expects() + ->write('closed.json', '[]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(ENDPOINT, []) + ->once() + ->andReturn(endpointResponse(200, '[]')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeFalse(); + }); + }); +}); diff --git a/tests/Unit/WpOrg/Api/ArrayCacheTest.php b/tests/Unit/WpOrg/Api/ArrayCacheTest.php deleted file mode 100644 index 3fb20fb..0000000 --- a/tests/Unit/WpOrg/Api/ArrayCacheTest.php +++ /dev/null @@ -1,61 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - describe('::read()', static function (): void { - test('hit', function (bool $expected): void { - $cache = new ArrayCache; - $cache->write('foo', $expected); - - $actual = $cache->read('foo'); - - expect($actual)->toBe($expected); - })->with([true, false]); - - test('miss', function (): void { - $cache = new ArrayCache; - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - }); - }); - - describe('::write()', static function (): void { - it('stores', function (bool $isClosed): void { - $cache = new ArrayCache; - - $cache->write('foo', $isClosed); - - expect($cache->read('foo'))->toBe($isClosed); - })->with([true, false]); - - test('last write wins', function (bool $first, bool $last): void { - $cache = new ArrayCache; - - $cache->write('foo', $first); - $cache->write('foo', $last); - - expect($cache->read('foo'))->toBe($last); - })->with([ - [true, false], - [false, true], - [true, true], - [false, false], - ]); - }); -}); diff --git a/tests/Unit/WpOrg/Api/CacheProxyTest.php b/tests/Unit/WpOrg/Api/CacheProxyTest.php deleted file mode 100644 index 6430538..0000000 --- a/tests/Unit/WpOrg/Api/CacheProxyTest.php +++ /dev/null @@ -1,88 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - describe('::read()', static function (): void { - test('when fast hits', function (bool $fastValue): void { - $fast = Mockery::spy(CacheInterface::class); - $fast->expects() - ->read() - ->with('foo') - ->andReturn($fastValue); - - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBe($fastValue); - $slow->shouldNotHaveReceived('read'); - $fast->shouldNotHaveReceived('write'); - $slow->shouldNotHaveReceived('write'); - })->with([true, false]); - - test('when fast misses while slow hits', function (bool $slowValue): void { - $fast = Mockery::spy(CacheInterface::class); - - $slow = Mockery::spy(CacheInterface::class); - $slow->expects() - ->read() - ->with('foo') - ->andReturn($slowValue); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBe($slowValue); - $fast->shouldHaveReceived('write', ['foo', $slowValue]); - $slow->shouldNotHaveReceived('write'); - })->with([true, false]); - - test('when both fast and slow miss', function (): void { - $fast = Mockery::spy(CacheInterface::class); - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBeNull(); - $fast->shouldNotHaveReceived('write'); - $slow->shouldNotHaveReceived('write'); - }); - }); - - describe('::write()', static function (): void { - it('forwards write to both fast and slow', function (bool $isClosed): void { - $fast = Mockery::spy(CacheInterface::class); - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $proxy->write('foo', $isClosed); - - $fast->shouldHaveReceived('write', ['foo', $isClosed]); - $slow->shouldHaveReceived('write', ['foo', $isClosed]); - })->with([true, false]); - }); -}); diff --git a/tests/Unit/WpOrg/Api/FileCacheTest.php b/tests/Unit/WpOrg/Api/FileCacheTest.php deleted file mode 100644 index 1c664ff..0000000 --- a/tests/Unit/WpOrg/Api/FileCacheTest.php +++ /dev/null @@ -1,118 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - dataset('slugs', static function (): array { - return [ - // Closed. - 'unused_permanent' => ['spam-stopgap', true, 'closed'], - // Not closed. - 'open' => ['hello-dolly', false, 'open'], - ]; - }); - - describe('::read()', static function (): void { - it('reads the first line of from {$slug}.txt', - function (string $slug, bool $expected, string $firstLine): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with("{$slug}.txt") - ->andReturn(123); - $composerCache->expects() - ->read() - ->with("{$slug}.txt") - ->andReturn($firstLine.PHP_EOL.$slug.PHP_EOL.'2006-01-02T15:04:05+07:00'.PHP_EOL); - - $cache = new FileCache($composerCache); - - $actual = $cache->read($slug); - - expect($actual)->toBe($expected); - })->with('slugs'); - - test('when missed or expired', function (false|int $age): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with('foo.txt') - ->andReturn($age); - - $cache = new FileCache($composerCache); - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - })->with([ - 'missed' => false, - 'expired' => 999_999, - ]); - - test('when unexpected content', function (false|string $content): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with('foo.txt') - ->andReturn(123); - $composerCache->expects() - ->read() - ->with('foo.txt') - ->andReturn($content); - - $cache = new FileCache($composerCache); - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - })->with([ - 'missed' => false, - 'unexpected' => 'not-closed-nor-open', - ]); - }); - - describe('::write()', static function (): void { - it('writes the result into {$slug}.txt as the first line', - function (string $slug, bool $isClosed, string $expected): void { - $composerCache = Mockery::spy(ComposerCache::class); - $cache = new FileCache($composerCache); - - $cache->write($slug, $isClosed); - - $composerCache->shouldHaveReceived('write', [ - "{$slug}.txt", - Mockery::on(static fn (string $actual) => str_starts_with($actual, $expected.PHP_EOL.$slug.PHP_EOL)), - ]); - })->with('slugs'); - - test('when composer cache is read only', function (string $slug, bool $isClosed): void { - $composerCache = Mockery::spy(ComposerCache::class); - $composerCache->expects() - ->isReadOnly() - ->andReturnTrue(); - - $cache = new FileCache($composerCache); - - $cache->write($slug, $isClosed); - - $composerCache->shouldNotHaveReceived('write'); - })->with('slugs'); - }); -}); From 1ec399d0cd04461423946eda114b7fc14e144bc8 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 04:05:11 +0100 Subject: [PATCH 2/8] wip --- AGENTS.md | 2 +- src/MarkClosedPluginAsAbandoned.php | 8 ++++---- src/WpPackages/Api/Client.php | 2 +- tests/Feature/WpPackages/Api/ClientTest.php | 22 ++++++++++----------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2fe735..f3a56f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ composer test # Integration tests use the testscript framework; scripts live in `testdata/script/*.txtar` -go test -count=1 -shuffle=on ./... +GOFLAGS=-mod=mod go test -count=1 -shuffle=on ./... # Run linters composer lint diff --git a/src/MarkClosedPluginAsAbandoned.php b/src/MarkClosedPluginAsAbandoned.php index c63a8d1..71ff656 100644 --- a/src/MarkClosedPluginAsAbandoned.php +++ b/src/MarkClosedPluginAsAbandoned.php @@ -4,7 +4,7 @@ namespace TypistTech\WpOrgClosedPlugin; -use Composer\Cache as ComposerCache; +use Composer\Cache; use Composer\Composer; use Composer\IO\IOInterface; use Composer\Package\CompletePackageInterface; @@ -20,8 +20,8 @@ public static function create(Composer $composer, IOInterface $io): self { $config = $composer->getConfig(); $cachePath = "{$config->get('cache-dir')}/wp-org-closed-plugin"; - $composerCache = new ComposerCache($io, $cachePath); - $composerCache->setReadOnly($config->get('cache-read-only')); + $cache = new Cache($io, $cachePath); + $cache->setReadOnly($config->get('cache-read-only')); return new self( new MultiUrlParser( @@ -30,7 +30,7 @@ public static function create(Composer $composer, IOInterface $io): self ), new Client( $composer->getLoop()->getHttpDownloader(), - $composerCache, + $cache, ), $io, ); diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php index bd82d1b..27aa634 100644 --- a/src/WpPackages/Api/Client.php +++ b/src/WpPackages/Api/Client.php @@ -8,7 +8,7 @@ use Composer\Downloader\TransportException; use Composer\Util\HttpDownloader; -final class Client +class Client { /** * The wp-packages.org endpoint that returns every closed wp-plugin slug as diff --git a/tests/Feature/WpPackages/Api/ClientTest.php b/tests/Feature/WpPackages/Api/ClientTest.php index d97b63e..9d50277 100644 --- a/tests/Feature/WpPackages/Api/ClientTest.php +++ b/tests/Feature/WpPackages/Api/ClientTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\WpPackages\Api; -use Composer\Cache as ComposerCache; +use Composer\Cache; use Composer\Downloader\TransportException; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; @@ -28,7 +28,7 @@ function endpointResponse(int $code, ?string $body): Response it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { $httpDownloader = $this->loop()->getHttpDownloader(); - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->allows()->write('closed.json', Mockery::any()); @@ -52,7 +52,7 @@ function endpointResponse(int $code, ?string $body): Response it('returns false for an empty or whitespace-only slug without any I/O', function (string $slug): void { $httpDownloader = Mockery::spy(HttpDownloader::class); - $cache = Mockery::spy(ComposerCache::class); + $cache = Mockery::spy(Cache::class); $client = new Client($httpDownloader, $cache); @@ -66,7 +66,7 @@ function endpointResponse(int $code, ?string $body): Response ]); it('revalidates with a derived If-Modified-Since and serves the cache on 304', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["spam-stopgap", "better-delete-revision"]'); $cache->allows()->getAge('closed.json')->andReturn(120); @@ -98,7 +98,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('writes the fetched body to cache on a cache miss', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->expects() @@ -117,7 +117,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('resolves the closed list at most once', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->expects()->read('closed.json')->once()->andReturnFalse(); $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->allows()->write('closed.json', '["spam-stopgap"]'); @@ -136,7 +136,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('fetches a changed list and overwrites the cache', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["spam-stopgap"]'); $cache->allows()->getAge('closed.json')->andReturn(120); $cache->expects() @@ -155,7 +155,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('keeps using the cached list when the endpoint is unreachable', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); $cache->allows()->getAge('closed.json')->andReturn(120); @@ -173,7 +173,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('treats the plugin as not closed when unreachable with an empty cache', function (): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); $cache->allows()->getAge('closed.json')->andReturnFalse(); @@ -190,7 +190,7 @@ function endpointResponse(int $code, ?string $body): Response }); it('keeps the cached list and skips caching an unusable response', function (?string $body): void { - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); $cache->allows()->getAge('closed.json')->andReturn(120); @@ -215,7 +215,7 @@ function endpointResponse(int $code, ?string $body): Response it('caches a legitimately empty closed list', function (): void { // A valid but empty `[]` is distinct from a malformed body: it is // cached and returned, rather than falling back. - $cache = Mockery::mock(ComposerCache::class); + $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->expects() From 9a39c34425b28231c3e98e38401f620442cbfbce Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 04:20:20 +0100 Subject: [PATCH 3/8] wip --- .github/workflows/test.yml | 27 +++------- composer.json | 4 +- src/WpPackages/Api/Client.php | 2 +- tests/Feature/TestCase.php | 19 ------- tests/Pest.php | 4 -- .../WpPackages/Api/ClientTest.php | 52 +++++++++---------- 6 files changed, 36 insertions(+), 72 deletions(-) delete mode 100644 tests/Feature/TestCase.php rename tests/{Feature => Unit}/WpPackages/Api/ClientTest.php (86%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a8252..4591053 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,17 +37,14 @@ jobs: needs: php-matrix strategy: matrix: - suite: &suite [unit, feature] php-version: ${{ fromJSON(needs.php-matrix.outputs.versions) }} dependency-versions: [highest, lowest] coverage: ['none'] include: - - suite: unit - php-version: ${{ needs.php-matrix.outputs.highest }} + - php-version: ${{ needs.php-matrix.outputs.highest }} dependency-versions: locked coverage: xdebug - - suite: feature - php-version: ${{ needs.php-matrix.outputs.highest }} + - php-version: ${{ needs.php-matrix.outputs.highest }} dependency-versions: locked coverage: xdebug @@ -66,27 +63,20 @@ jobs: with: dependency-versions: ${{ matrix.dependency-versions }} - - run: composer "test:${SUITE}" -- --ci --coverage-clover "coverage-${SUITE}.xml" + - run: composer test -- --ci --coverage-clover coverage.xml if: matrix.coverage == 'xdebug' - env: - SUITE: ${{ matrix.suite }} - - run: composer "test:${SUITE}" -- --ci + - run: composer test -- --ci if: matrix.coverage != 'xdebug' - env: - SUITE: ${{ matrix.suite }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: matrix.coverage == 'xdebug' with: - name: coverage-${{ matrix.suite }} - path: coverage-${{ matrix.suite }}.xml + name: coverage + path: coverage.xml codecov: needs: test - strategy: - matrix: - suite: *suite runs-on: ubuntu-latest permissions: id-token: write @@ -97,15 +87,14 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: coverage-${{ matrix.suite }} + name: coverage - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: use_oidc: true fail_ci_if_error: true disable_search: true - files: coverage-${{ matrix.suite }}.xml - flags: ${{ matrix.suite }} + files: coverage.xml e2e: name: e2e (PHP ${{ matrix.php-version }}, Composer ${{ matrix.composer-version }}) diff --git a/composer.json b/composer.json index b071889..dc25806 100644 --- a/composer.json +++ b/composer.json @@ -66,8 +66,6 @@ }, "scripts": { "lint": "phpstan analyse", - "test": "pest", - "test:feature": "pest --group=feature", - "test:unit": "pest --group=unit" + "test": "pest" } } diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php index 27aa634..933d90e 100644 --- a/src/WpPackages/Api/Client.php +++ b/src/WpPackages/Api/Client.php @@ -14,7 +14,7 @@ class Client * The wp-packages.org endpoint that returns every closed wp-plugin slug as * a single JSON array, e.g. ["better-delete-revision", "spam-stopgap"]. */ - private const string URL = 'https://wp-packages.org/api/packages/wp-plugin/closed'; + public const string URL = 'https://wp-packages.org/api/packages/wp-plugin/closed'; private const string CACHE_KEY = 'closed.json'; diff --git a/tests/Feature/TestCase.php b/tests/Feature/TestCase.php deleted file mode 100644 index dd4c796..0000000 --- a/tests/Feature/TestCase.php +++ /dev/null @@ -1,19 +0,0 @@ -getLoop(); - } -} diff --git a/tests/Pest.php b/tests/Pest.php index 18d90f9..d3a3718 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -9,10 +9,6 @@ | */ -pest()->group('feature') - ->extend(\Tests\Feature\TestCase::class) - ->in('Feature'); - pest()->group('unit') ->in('Unit'); diff --git a/tests/Feature/WpPackages/Api/ClientTest.php b/tests/Unit/WpPackages/Api/ClientTest.php similarity index 86% rename from tests/Feature/WpPackages/Api/ClientTest.php rename to tests/Unit/WpPackages/Api/ClientTest.php index 9d50277..89a7137 100644 --- a/tests/Feature/WpPackages/Api/ClientTest.php +++ b/tests/Unit/WpPackages/Api/ClientTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); -namespace Tests\Feature\WpPackages\Api; +namespace Tests\Unit\WpPackages\Api; use Composer\Cache; use Composer\Downloader\TransportException; +use Composer\Factory; +use Composer\IO\NullIO; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; use Mockery; @@ -13,20 +15,12 @@ covers(Client::class); -const ENDPOINT = 'https://wp-packages.org/api/packages/wp-plugin/closed'; - -/** - * Build a Response as the endpoint would return it. - */ -function endpointResponse(int $code, ?string $body): Response -{ - return new Response(['url' => ENDPOINT], $code, [], $body); -} - describe(Client::class, static function (): void { describe('::isClosed()', static function (): void { it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { - $httpDownloader = $this->loop()->getHttpDownloader(); + $httpDownloader = Factory::create(new NullIO, null, true, true) + ->getLoop() + ->getHttpDownloader(); $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); @@ -71,11 +65,12 @@ function endpointResponse(int $code, ?string $body): Response $cache->allows()->getAge('closed.json')->andReturn(120); $captured = null; + $response = new Response(['url' => Client::URL], 304, [], ''); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, Mockery::capture($captured)) + ->get(Client::URL, Mockery::capture($captured)) ->once() - ->andReturn(endpointResponse(304, '')); + ->andReturn($response); $before = time(); $client = new Client($httpDownloader, $cache); @@ -105,11 +100,12 @@ function endpointResponse(int $code, ?string $body): Response ->write('closed.json', '["spam-stopgap"]') ->once(); + $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, []) + ->get(Client::URL, []) ->once() - ->andReturn(endpointResponse(200, '["spam-stopgap"]')); + ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -122,11 +118,12 @@ function endpointResponse(int $code, ?string $body): Response $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->allows()->write('closed.json', '["spam-stopgap"]'); + $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, []) + ->get(Client::URL, []) ->once() - ->andReturn(endpointResponse(200, '["spam-stopgap"]')); + ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -143,10 +140,11 @@ function endpointResponse(int $code, ?string $body): Response ->write('closed.json', '["better-delete-revision"]') ->once(); + $response = new Response(['url' => Client::URL], 200, [], '["better-delete-revision"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, Mockery::any()) - ->andReturn(endpointResponse(200, '["better-delete-revision"]')); + ->get(Client::URL, Mockery::any()) + ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -161,7 +159,7 @@ function endpointResponse(int $code, ?string $body): Response $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, Mockery::any()) + ->get(Client::URL, Mockery::any()) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -179,7 +177,7 @@ function endpointResponse(int $code, ?string $body): Response $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, []) + ->get(Client::URL, []) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -194,10 +192,11 @@ function endpointResponse(int $code, ?string $body): Response $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); $cache->allows()->getAge('closed.json')->andReturn(120); + $response = new Response(['url' => Client::URL], 200, [], $body); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, Mockery::any()) - ->andReturn(endpointResponse(200, $body)); + ->get(Client::URL, Mockery::any()) + ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -222,11 +221,12 @@ function endpointResponse(int $code, ?string $body): Response ->write('closed.json', '[]') ->once(); + $response = new Response(['url' => Client::URL], 200, [], '[]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(ENDPOINT, []) + ->get(Client::URL, []) ->once() - ->andReturn(endpointResponse(200, '[]')); + ->andReturn($response); $client = new Client($httpDownloader, $cache); From 00866e2fb2dc2af75b171792305feeaa9eb6c242 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 04:34:58 +0100 Subject: [PATCH 4/8] wip --- src/WpPackages/Api/Client.php | 35 +++++++-------- tests/Unit/WpPackages/Api/ClientTest.php | 57 ++++++++---------------- 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php index 933d90e..329e4aa 100644 --- a/src/WpPackages/Api/Client.php +++ b/src/WpPackages/Api/Client.php @@ -18,6 +18,8 @@ class Client private const string CACHE_KEY = 'closed.json'; + private const int CACHE_TTL_SECONDS = 600; + /** @var array|null */ private ?array $closedSlugs = null; @@ -44,25 +46,30 @@ private function closedSlugs(): array } /** - * Revalidate the cached list against the endpoint and return the closed set. + * Return the cached list while it is fresh, otherwise refresh from the endpoint. * - * A `304 Not Modified`, a transport failure, or an empty/malformed body all - * fall back to the cached copy instead of discarding known data. + * A transport failure or an empty/malformed body falls back to the cached + * copy instead of discarding known data. * * @return array */ private function load(): array { $cached = $this->cache->read(self::CACHE_KEY); - $fallback = $this->decode($cached) ?? []; + $cachedSlugs = $this->decode($cached); + if ($cachedSlugs !== null && $this->isCacheFresh()) { + return $cachedSlugs; + } + + $fallback = $cachedSlugs ?? []; try { - $response = $this->httpDownloader->get(self::URL, $this->options()); + $response = $this->httpDownloader->get(self::URL); } catch (TransportException) { return $fallback; } - $body = $response->getStatusCode() === 304 ? null : $response->getBody(); + $body = $response->getBody(); if ($body === null) { return $fallback; } @@ -77,23 +84,11 @@ private function load(): array return $fresh; } - /** - * A conditional request whenever something is cached, so an unchanged list - * returns a cheap `304 Not Modified` (Composer even synthesises one under - * `COMPOSER_DISABLE_NETWORK`). The marker is the cache entry's own age. - * - * @return array{http?: array{header: list}} - */ - private function options(): array + private function isCacheFresh(): bool { $age = $this->cache->getAge(self::CACHE_KEY); - if ($age === false) { - return []; - } - - $since = gmdate('D, d M Y H:i:s', time() - $age) . ' GMT'; - return ['http' => ['header' => ['If-Modified-Since: ' . $since]]]; + return $age !== false && $age < self::CACHE_TTL_SECONDS; } /** diff --git a/tests/Unit/WpPackages/Api/ClientTest.php b/tests/Unit/WpPackages/Api/ClientTest.php index 89a7137..79d9a6e 100644 --- a/tests/Unit/WpPackages/Api/ClientTest.php +++ b/tests/Unit/WpPackages/Api/ClientTest.php @@ -24,7 +24,6 @@ $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); - $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->allows()->write('closed.json', Mockery::any()); $client = new Client($httpDownloader, $cache); @@ -59,35 +58,18 @@ 'whitespace' => [' '], ]); - it('revalidates with a derived If-Modified-Since and serves the cache on 304', function (): void { + it('uses fresh cache without sending an HTTP request', function (): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["spam-stopgap", "better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(120); + $cache->allows()->getAge('closed.json')->andReturn(599); - $captured = null; - $response = new Response(['url' => Client::URL], 304, [], ''); $httpDownloader = Mockery::mock(HttpDownloader::class); - $httpDownloader->expects() - ->get(Client::URL, Mockery::capture($captured)) - ->once() - ->andReturn($response); + $httpDownloader->shouldNotReceive('get'); - $before = time(); $client = new Client($httpDownloader, $cache); - $isClosed = $client->isClosed('spam-stopgap'); - $isOpen = $client->isClosed('hello-dolly'); - $after = time(); - - expect($isClosed)->toBeTrue() - ->and($isOpen)->toBeFalse(); - // The marker is derived from time() during the call: accept any - // second the call could have observed to avoid a clock-tick race. - $acceptable = []; - for ($now = $before; $now <= $after; $now++) { - $acceptable[] = 'If-Modified-Since: ' . gmdate('D, d M Y H:i:s', $now - 120) . ' GMT'; - } - expect(array_intersect($captured['http']['header'], $acceptable))->not->toBeEmpty(); + expect($client->isClosed('spam-stopgap'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse(); $cache->shouldNotHaveReceived('write'); }); @@ -95,7 +77,6 @@ it('writes the fetched body to cache on a cache miss', function (): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); - $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->expects() ->write('closed.json', '["spam-stopgap"]') ->once(); @@ -103,7 +84,7 @@ $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, []) + ->get(Client::URL) ->once() ->andReturn($response); @@ -115,13 +96,12 @@ it('resolves the closed list at most once', function (): void { $cache = Mockery::mock(Cache::class); $cache->expects()->read('closed.json')->once()->andReturnFalse(); - $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->allows()->write('closed.json', '["spam-stopgap"]'); $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, []) + ->get(Client::URL) ->once() ->andReturn($response); @@ -132,10 +112,10 @@ ->and($client->isClosed('spam-stopgap'))->toBeTrue(); }); - it('fetches a changed list and overwrites the cache', function (): void { + it('fetches a changed list from stale cache and overwrites the cache', function (): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["spam-stopgap"]'); - $cache->allows()->getAge('closed.json')->andReturn(120); + $cache->allows()->getAge('closed.json')->andReturn(600); $cache->expects() ->write('closed.json', '["better-delete-revision"]') ->once(); @@ -143,7 +123,8 @@ $response = new Response(['url' => Client::URL], 200, [], '["better-delete-revision"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, Mockery::any()) + ->get(Client::URL) + ->once() ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -152,14 +133,14 @@ ->and($client->isClosed('spam-stopgap'))->toBeFalse(); }); - it('keeps using the cached list when the endpoint is unreachable', function (): void { + it('keeps using the stale cached list when the endpoint is unreachable', function (): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(120); + $cache->allows()->getAge('closed.json')->andReturn(600); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, Mockery::any()) + ->get(Client::URL) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -173,11 +154,10 @@ it('treats the plugin as not closed when unreachable with an empty cache', function (): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); - $cache->allows()->getAge('closed.json')->andReturnFalse(); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, []) + ->get(Client::URL) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -190,12 +170,12 @@ it('keeps the cached list and skips caching an unusable response', function (?string $body): void { $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(120); + $cache->allows()->getAge('closed.json')->andReturn(600); $response = new Response(['url' => Client::URL], 200, [], $body); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, Mockery::any()) + ->get(Client::URL) ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -216,7 +196,6 @@ // cached and returned, rather than falling back. $cache = Mockery::mock(Cache::class); $cache->allows()->read('closed.json')->andReturnFalse(); - $cache->allows()->getAge('closed.json')->andReturnFalse(); $cache->expects() ->write('closed.json', '[]') ->once(); @@ -224,7 +203,7 @@ $response = new Response(['url' => Client::URL], 200, [], '[]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL, []) + ->get(Client::URL) ->once() ->andReturn($response); From a0557c8c0edb6c12f36585dd993c11a4472d7443 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 04:35:26 +0100 Subject: [PATCH 5/8] wip --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 120000 => 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From bcdf15c7136f27241036a5f62c40e39966926c33 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 07:22:01 +0100 Subject: [PATCH 6/8] wip --- src/MarkClosedPluginAsAbandoned.php | 1 + src/WpPackages/Api/Client.php | 62 +++++++++-- tests/Unit/WpPackages/Api/ClientTest.php | 132 ++++++++++++++++++----- 3 files changed, 162 insertions(+), 33 deletions(-) diff --git a/src/MarkClosedPluginAsAbandoned.php b/src/MarkClosedPluginAsAbandoned.php index 71ff656..d3f83c3 100644 --- a/src/MarkClosedPluginAsAbandoned.php +++ b/src/MarkClosedPluginAsAbandoned.php @@ -31,6 +31,7 @@ public static function create(Composer $composer, IOInterface $io): self new Client( $composer->getLoop()->getHttpDownloader(), $cache, + $composer->getEventDispatcher(), ), $io, ); diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php index 329e4aa..0a315c1 100644 --- a/src/WpPackages/Api/Client.php +++ b/src/WpPackages/Api/Client.php @@ -6,6 +6,10 @@ use Composer\Cache; use Composer\Downloader\TransportException; +use Composer\EventDispatcher\EventDispatcher; +use Composer\Plugin\PluginEvents; +use Composer\Plugin\PostFileDownloadEvent; +use Composer\Plugin\PreFileDownloadEvent; use Composer\Util\HttpDownloader; class Client @@ -16,8 +20,6 @@ class Client */ public const string URL = 'https://wp-packages.org/api/packages/wp-plugin/closed'; - private const string CACHE_KEY = 'closed.json'; - private const int CACHE_TTL_SECONDS = 600; /** @var array|null */ @@ -26,6 +28,7 @@ class Client public function __construct( private readonly HttpDownloader $httpDownloader, private readonly Cache $cache, + private readonly ?EventDispatcher $eventDispatcher = null, ) {} public function isClosed(string $slug): bool @@ -55,20 +58,33 @@ private function closedSlugs(): array */ private function load(): array { - $cached = $this->cache->read(self::CACHE_KEY); + $download = $this->download(); + $cached = $this->cache->read($download['cacheKey']); $cachedSlugs = $this->decode($cached); - if ($cachedSlugs !== null && $this->isCacheFresh()) { + if ($cachedSlugs !== null && $this->isCacheFresh($download['cacheKey'])) { return $cachedSlugs; } $fallback = $cachedSlugs ?? []; try { - $response = $this->httpDownloader->get(self::URL); + $response = $this->httpDownloader->get($download['url'], $download['options']); } catch (TransportException) { return $fallback; } + if ($this->eventDispatcher !== null) { + $postFileDownloadEvent = new PostFileDownloadEvent( + PluginEvents::POST_FILE_DOWNLOAD, + null, + null, + $download['url'], + 'metadata', + ['response' => $response, 'wp-packages-api-client' => $this], + ); + $this->eventDispatcher->dispatch($postFileDownloadEvent->getName(), $postFileDownloadEvent); + } + $body = $response->getBody(); if ($body === null) { return $fallback; @@ -79,18 +95,48 @@ private function load(): array return $fallback; } - $this->cache->write(self::CACHE_KEY, $body); + $this->cache->write($download['cacheKey'], $body); return $fresh; } - private function isCacheFresh(): bool + private function isCacheFresh(string $cacheKey): bool { - $age = $this->cache->getAge(self::CACHE_KEY); + $age = $this->cache->getAge($cacheKey); return $age !== false && $age < self::CACHE_TTL_SECONDS; } + /** + * @return array{url: non-empty-string, options: mixed[], cacheKey: string} + */ + private function download(): array + { + $url = self::URL; + $options = []; + $cacheKey = $url; + + if ($this->eventDispatcher === null) { + return ['url' => $url, 'options' => $options, 'cacheKey' => $cacheKey]; + } + + $preFileDownloadEvent = new PreFileDownloadEvent( + PluginEvents::PRE_FILE_DOWNLOAD, + $this->httpDownloader, + $url, + 'metadata', + ['wp-packages-api-client' => $this], + ); + $preFileDownloadEvent->setTransportOptions($options); + $this->eventDispatcher->dispatch($preFileDownloadEvent->getName(), $preFileDownloadEvent); + + $url = $preFileDownloadEvent->getProcessedUrl(); + $options = $preFileDownloadEvent->getTransportOptions(); + $cacheKey = $preFileDownloadEvent->getCustomCacheKey() ?? $url; + + return ['url' => $url, 'options' => $options, 'cacheKey' => $cacheKey]; + } + /** * Decode the endpoint's JSON array of slugs into a set keyed by slug, or * null when the payload is not a JSON array so the caller can fall back. diff --git a/tests/Unit/WpPackages/Api/ClientTest.php b/tests/Unit/WpPackages/Api/ClientTest.php index 79d9a6e..71cdc3b 100644 --- a/tests/Unit/WpPackages/Api/ClientTest.php +++ b/tests/Unit/WpPackages/Api/ClientTest.php @@ -6,8 +6,12 @@ use Composer\Cache; use Composer\Downloader\TransportException; +use Composer\EventDispatcher\EventDispatcher; use Composer\Factory; use Composer\IO\NullIO; +use Composer\Plugin\PluginEvents; +use Composer\Plugin\PostFileDownloadEvent; +use Composer\Plugin\PreFileDownloadEvent; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; use Mockery; @@ -23,8 +27,8 @@ ->getHttpDownloader(); $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturnFalse(); - $cache->allows()->write('closed.json', Mockery::any()); + $cache->allows()->read(Client::URL)->andReturnFalse(); + $cache->allows()->write(Client::URL, Mockery::any()); $client = new Client($httpDownloader, $cache); @@ -60,8 +64,8 @@ it('uses fresh cache without sending an HTTP request', function (): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturn('["spam-stopgap", "better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(599); + $cache->allows()->read(Client::URL)->andReturn('["spam-stopgap", "better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(599); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->shouldNotReceive('get'); @@ -76,15 +80,15 @@ it('writes the fetched body to cache on a cache miss', function (): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->read(Client::URL)->andReturnFalse(); $cache->expects() - ->write('closed.json', '["spam-stopgap"]') + ->write(Client::URL, '["spam-stopgap"]') ->once(); $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->once() ->andReturn($response); @@ -95,13 +99,13 @@ it('resolves the closed list at most once', function (): void { $cache = Mockery::mock(Cache::class); - $cache->expects()->read('closed.json')->once()->andReturnFalse(); - $cache->allows()->write('closed.json', '["spam-stopgap"]'); + $cache->expects()->read(Client::URL)->once()->andReturnFalse(); + $cache->allows()->write(Client::URL, '["spam-stopgap"]'); $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->once() ->andReturn($response); @@ -114,16 +118,16 @@ it('fetches a changed list from stale cache and overwrites the cache', function (): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturn('["spam-stopgap"]'); - $cache->allows()->getAge('closed.json')->andReturn(600); + $cache->allows()->read(Client::URL)->andReturn('["spam-stopgap"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); $cache->expects() - ->write('closed.json', '["better-delete-revision"]') + ->write(Client::URL, '["better-delete-revision"]') ->once(); $response = new Response(['url' => Client::URL], 200, [], '["better-delete-revision"]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->once() ->andReturn($response); @@ -135,12 +139,12 @@ it('keeps using the stale cached list when the endpoint is unreachable', function (): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(600); + $cache->allows()->read(Client::URL)->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -153,11 +157,11 @@ it('treats the plugin as not closed when unreachable with an empty cache', function (): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->read(Client::URL)->andReturnFalse(); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->andThrow(new TransportException('Connection refused')); $client = new Client($httpDownloader, $cache); @@ -169,13 +173,13 @@ it('keeps the cached list and skips caching an unusable response', function (?string $body): void { $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturn('["better-delete-revision"]'); - $cache->allows()->getAge('closed.json')->andReturn(600); + $cache->allows()->read(Client::URL)->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); $response = new Response(['url' => Client::URL], 200, [], $body); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->andReturn($response); $client = new Client($httpDownloader, $cache); @@ -195,15 +199,15 @@ // A valid but empty `[]` is distinct from a malformed body: it is // cached and returned, rather than falling back. $cache = Mockery::mock(Cache::class); - $cache->allows()->read('closed.json')->andReturnFalse(); + $cache->allows()->read(Client::URL)->andReturnFalse(); $cache->expects() - ->write('closed.json', '[]') + ->write(Client::URL, '[]') ->once(); $response = new Response(['url' => Client::URL], 200, [], '[]'); $httpDownloader = Mockery::mock(HttpDownloader::class); $httpDownloader->expects() - ->get(Client::URL) + ->get(Client::URL, []) ->once() ->andReturn($response); @@ -211,5 +215,83 @@ expect($client->isClosed('spam-stopgap'))->toBeFalse(); }); + + it('dispatches file download events and uses the processed URL as the cache key', function (): void { + $processedUrl = 'https://mirror.example.com/closed.json'; + $options = ['http' => ['header' => ['X-Mirror: 1']]]; + $response = new Response(['url' => $processedUrl], 200, [], '["mirrored-plugin"]'); + + $cache = Mockery::mock(Cache::class); + $cache->allows()->read($processedUrl)->andReturnFalse(); + $cache->expects() + ->write($processedUrl, '["mirrored-plugin"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get($processedUrl, $options) + ->once() + ->andReturn($response); + + $eventDispatcher = Mockery::mock(EventDispatcher::class); + $eventDispatcher->expects() + ->dispatch(PluginEvents::PRE_FILE_DOWNLOAD, Mockery::on( + static function (PreFileDownloadEvent $event) use ($processedUrl, $options): bool { + expect($event->getProcessedUrl())->toBe(Client::URL) + ->and($event->getType())->toBe('metadata'); + + $event->setProcessedUrl($processedUrl); + $event->setTransportOptions($options); + + return true; + }, + )) + ->once(); + $eventDispatcher->expects() + ->dispatch(PluginEvents::POST_FILE_DOWNLOAD, Mockery::on( + static fn (PostFileDownloadEvent $event): bool => $event->getUrl() === $processedUrl + && $event->getType() === 'metadata' + && ($event->getContext()['response'] ?? null) === $response, + )) + ->once(); + + $client = new Client($httpDownloader, $cache, $eventDispatcher); + + expect($client->isClosed('mirrored-plugin'))->toBeTrue(); + }); + + it('uses a custom cache key from the pre file download event', function (): void { + $customCacheKey = 'custom-closed-list.json'; + $response = new Response(['url' => Client::URL], 200, [], '["custom-cache-plugin"]'); + + $cache = Mockery::mock(Cache::class); + $cache->allows()->read($customCacheKey)->andReturnFalse(); + $cache->expects() + ->write($customCacheKey, '["custom-cache-plugin"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $eventDispatcher = Mockery::mock(EventDispatcher::class); + $eventDispatcher->expects() + ->dispatch(PluginEvents::PRE_FILE_DOWNLOAD, Mockery::on( + static function (PreFileDownloadEvent $event) use ($customCacheKey): bool { + $event->setCustomCacheKey($customCacheKey); + + return true; + }, + )) + ->once(); + $eventDispatcher->allows() + ->dispatch(PluginEvents::POST_FILE_DOWNLOAD, Mockery::type(PostFileDownloadEvent::class)); + + $client = new Client($httpDownloader, $cache, $eventDispatcher); + + expect($client->isClosed('custom-cache-plugin'))->toBeTrue(); + }); }); }); From 9788cfb3dc4bfe4375158164db4e8fe74337f2a2 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 08:48:10 +0100 Subject: [PATCH 7/8] Add back Feature tests --- tests/Feature/WpPackages/Api/ClientTest.php | 40 +++++++++++++++++++++ tests/Pest.php | 3 ++ 2 files changed, 43 insertions(+) create mode 100644 tests/Feature/WpPackages/Api/ClientTest.php diff --git a/tests/Feature/WpPackages/Api/ClientTest.php b/tests/Feature/WpPackages/Api/ClientTest.php new file mode 100644 index 0000000..054dfbf --- /dev/null +++ b/tests/Feature/WpPackages/Api/ClientTest.php @@ -0,0 +1,40 @@ +setReadOnly(true); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed($slug))->toBe($expected); + })->group('network')->with([ + // Closed. + 'author_request_permanent' => ['paid-memberships-pro', true], + 'guideline_violation_permanent' => ['no-longer-in-directory', true], + 'security_issue' => ['better-delete-revision', true], + 'unused_permanent' => ['spam-stopgap', true], + + // Not closed. + 'open' => ['hello-dolly', false], + 'not_found' => ['not-found-foo-bar-baz-qux', false], + 'empty_slug' => ['', false], + 'whitespace' => [' ', false], + ]); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index d3a3718..a0f7db7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -9,6 +9,9 @@ | */ +pest()->group('feature') + ->in('Feature'); + pest()->group('unit') ->in('Unit'); From 679058401b55a8dfd1f7369252681687509513bd Mon Sep 17 00:00:00 2001 From: TangRufus Date: Thu, 18 Jun 2026 09:01:46 +0100 Subject: [PATCH 8/8] tmp refactor --- src/WpPackages/Api/Client.php | 15 +++---- tests/Unit/WpPackages/Api/ClientTest.php | 50 ++++++++++++------------ 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php index 0a315c1..787e9d7 100644 --- a/src/WpPackages/Api/Client.php +++ b/src/WpPackages/Api/Client.php @@ -34,18 +34,13 @@ public function __construct( public function isClosed(string $slug): bool { $slug = trim($slug); + if ($slug === '') { + return false; + } - return $slug !== '' && isset($this->closedSlugs()[$slug]); - } + $this->closedSlugs ??= $this->load(); - /** - * The closed slugs as a set keyed by slug, resolved at most once per run. - * - * @return array - */ - private function closedSlugs(): array - { - return $this->closedSlugs ??= $this->load(); + return array_key_exists($slug, $this->closedSlugs); } /** diff --git a/tests/Unit/WpPackages/Api/ClientTest.php b/tests/Unit/WpPackages/Api/ClientTest.php index 71cdc3b..6080cd6 100644 --- a/tests/Unit/WpPackages/Api/ClientTest.php +++ b/tests/Unit/WpPackages/Api/ClientTest.php @@ -21,31 +21,31 @@ describe(Client::class, static function (): void { describe('::isClosed()', static function (): void { - it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { - $httpDownloader = Factory::create(new NullIO, null, true, true) - ->getLoop() - ->getHttpDownloader(); - - $cache = Mockery::mock(Cache::class); - $cache->allows()->read(Client::URL)->andReturnFalse(); - $cache->allows()->write(Client::URL, Mockery::any()); - - $client = new Client($httpDownloader, $cache); - - expect($client->isClosed($slug))->toBe($expected); - })->group('network')->with([ - // Closed. - 'author_request_permanent' => ['paid-memberships-pro', true], - 'guideline_violation_permanent' => ['no-longer-in-directory', true], - 'security_issue' => ['better-delete-revision', true], - 'unused_permanent' => ['spam-stopgap', true], - - // Not closed. - 'open' => ['hello-dolly', false], - 'not_found' => ['not-found-foo-bar-baz-qux', false], - 'empty_slug' => ['', false], - 'whitespace' => [' ', false], - ]); +// it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { +// $httpDownloader = Factory::create(new NullIO, null, true, true) +// ->getLoop() +// ->getHttpDownloader(); +// +// $cache = Mockery::mock(Cache::class); +// $cache->allows()->read(Client::URL)->andReturnFalse(); +// $cache->allows()->write(Client::URL, Mockery::any()); +// +// $client = new Client($httpDownloader, $cache); +// +// expect($client->isClosed($slug))->toBe($expected); +// })->with([ +// // Closed. +// 'author_request_permanent' => ['paid-memberships-pro', true], +// 'guideline_violation_permanent' => ['no-longer-in-directory', true], +// 'security_issue' => ['better-delete-revision', true], +// 'unused_permanent' => ['spam-stopgap', true], +// +// // Not closed. +// 'open' => ['hello-dolly', false], +// 'not_found' => ['not-found-foo-bar-baz-qux', false], +// 'empty_slug' => ['', false], +// 'whitespace' => [' ', false], +// ]); it('returns false for an empty or whitespace-only slug without any I/O', function (string $slug): void { $httpDownloader = Mockery::spy(HttpDownloader::class);