CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
WordPress only renders the "Enable auto-updates" toggle for a plugin that appears in the update_plugins transient's response or no_update list, which is what sets core's update-supported flag. UpdateChecker only populated the response side (when a newer release existed), so between releases the plugin was absent from the transient and the toggle never showed. provideUpdate() now returns a no_update payload (installed version, empty package) whenever no newer release is offered — including when the release lookup fails — so the plugin stays in the transient and the toggle appears. The response path (one-click and unattended updates) is unchanged. Bumps to 1.1.1 so the fix ships to installed sites via the self-updater. Co-Authored-By: Claude Opus 4.8 <[email protected]>
235 lines
9.2 KiB
PHP
235 lines
9.2 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Update;
|
|
|
|
use Brain\Monkey\Filters;
|
|
use Brain\Monkey\Functions;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
use Unsupervised\Schedular\Update\UpdateChecker;
|
|
|
|
class UpdateCheckerTest extends TestCase
|
|
{
|
|
private const PLUGIN_FILE = 'unsupervised-schedular/unsupervised-schedular.php';
|
|
private const PACKAGE_URL = 'https://git.unsupervised.ca/attachments/abc123';
|
|
|
|
/** Stub a successful Gitea API response with the given decoded body. */
|
|
private function stubApiResponse(int $code, mixed $body): void
|
|
{
|
|
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => $code]]);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
Functions\when('wp_remote_retrieve_response_code')->justReturn($code);
|
|
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode($body));
|
|
}
|
|
|
|
private function release(string $tag, array $assets): array
|
|
{
|
|
return ['tag_name' => $tag, 'assets' => $assets];
|
|
}
|
|
|
|
private function zipAsset(string $name = 'unsupervised-schedular-9.9.9.zip'): array
|
|
{
|
|
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
|
|
}
|
|
|
|
/**
|
|
* The payload provideUpdate() returns when no newer release is offered.
|
|
* Core files this under the transient's `no_update` list, which is what
|
|
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
|
|
* the test bootstrap.
|
|
*/
|
|
private function noUpdatePayload(): array
|
|
{
|
|
return [
|
|
'slug' => 'unsupervised-schedular',
|
|
'version' => '1.0.0',
|
|
'url' => UpdateChecker::REPO_URL,
|
|
'package' => '',
|
|
];
|
|
}
|
|
|
|
public function testRegisterHooksHostnameFilter(): void
|
|
{
|
|
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
|
|
Filters\expectAdded('plugin_row_meta')->once();
|
|
|
|
(new UpdateChecker())->register();
|
|
}
|
|
|
|
public function testFilterRowMetaReplacesViewDetailsLinkForThisPlugin(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
|
|
$meta = [
|
|
'Version 1.0.0',
|
|
'<a href="https://wordpress.org/plugin-install.php?...&plugin=unsupervised-schedular" class="thickbox open-plugin-details-modal">View details</a>',
|
|
];
|
|
|
|
$result = (new UpdateChecker())->filterRowMeta($meta, self::PLUGIN_FILE);
|
|
|
|
// Core's thickbox modal link is gone.
|
|
foreach ($result as $item) {
|
|
self::assertStringNotContainsString('open-plugin-details-modal', $item);
|
|
}
|
|
// Replaced with a new-tab link to the Gitea release tag for USC_VERSION (1.0.0 in tests).
|
|
self::assertStringContainsString(
|
|
UpdateChecker::REPO_URL . '/releases/tag/v1.0.0',
|
|
end($result)
|
|
);
|
|
self::assertStringContainsString('target="_blank"', end($result));
|
|
}
|
|
|
|
public function testFilterRowMetaLeavesOtherPluginsUntouched(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
|
|
$meta = ['<a href="#" class="thickbox open-plugin-details-modal">View details</a>'];
|
|
|
|
$result = (new UpdateChecker())->filterRowMeta($meta, 'other-plugin/other-plugin.php');
|
|
|
|
self::assertSame($meta, $result);
|
|
}
|
|
|
|
public function testOffersUpdateWhenReleaseIsNewer(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
// USC_VERSION is 1.0.0 in the test bootstrap.
|
|
$this->stubApiResponse(200, $this->release('v9.9.9', [$this->zipAsset()]));
|
|
Functions\expect('set_transient')->once()->with(
|
|
UpdateChecker::TRANSIENT,
|
|
['version' => '9.9.9', 'package' => self::PACKAGE_URL],
|
|
\Mockery::type('int')
|
|
);
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
self::assertSame(
|
|
[
|
|
'slug' => 'unsupervised-schedular',
|
|
'version' => '9.9.9',
|
|
'url' => UpdateChecker::REPO_URL,
|
|
'package' => self::PACKAGE_URL,
|
|
],
|
|
$result
|
|
);
|
|
}
|
|
|
|
public function testReturnsUpdateUnchangedForOtherPlugins(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\expect('wp_remote_get')->never();
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], 'other-plugin/other-plugin.php');
|
|
|
|
self::assertFalse($result);
|
|
}
|
|
|
|
public function testNoUpdatePayloadWhenReleaseIsNotNewer(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
$this->stubApiResponse(200, $this->release('v1.0.0', [$this->zipAsset('unsupervised-schedular-1.0.0.zip')]));
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
// Current version → core files this under `no_update` so the
|
|
// auto-update toggle stays visible; no package to install.
|
|
self::assertSame($this->noUpdatePayload(), $result);
|
|
}
|
|
|
|
public function testUsesCachedReleaseWithoutHittingApi(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(['version' => '2.0.0', 'package' => self::PACKAGE_URL]);
|
|
Functions\expect('wp_remote_get')->never();
|
|
Functions\expect('set_transient')->never();
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
self::assertIsArray($result);
|
|
self::assertSame('2.0.0', $result['version']);
|
|
}
|
|
|
|
public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('wp_remote_get')->justReturn('irrelevant');
|
|
Functions\when('is_wp_error')->justReturn(true);
|
|
// A failed lookup is cached too, so a down Gitea is not re-polled
|
|
// on every admin page load.
|
|
Functions\expect('set_transient')->once()->with(
|
|
UpdateChecker::TRANSIENT,
|
|
['version' => '', 'package' => ''],
|
|
\Mockery::type('int')
|
|
);
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
// Even with the lookup failed we still return the `no_update` payload,
|
|
// so the auto-update toggle does not flicker away during a Gitea blip.
|
|
self::assertSame($this->noUpdatePayload(), $result);
|
|
}
|
|
|
|
public function testNon200ResponseReturnsNoUpdatePayload(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
$this->stubApiResponse(404, ['message' => 'Not Found']);
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
self::assertSame($this->noUpdatePayload(), $result);
|
|
}
|
|
|
|
public function testPicksFirstZipAssetAndSkipsOthers(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
$this->stubApiResponse(200, $this->release('v9.9.9', [
|
|
['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
|
|
$this->zipAsset(),
|
|
]));
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
self::assertIsArray($result);
|
|
self::assertSame(self::PACKAGE_URL, $result['package']);
|
|
}
|
|
|
|
public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
$this->stubApiResponse(200, $this->release('v9.9.9', [
|
|
['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
|
|
]));
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
// No installable package means no update to offer, but we still keep
|
|
// the plugin in `no_update` so the toggle shows.
|
|
self::assertSame($this->noUpdatePayload(), $result);
|
|
}
|
|
|
|
public function testMalformedApiBodyOffersNoUpdatePayload(): void
|
|
{
|
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
|
Functions\when('get_transient')->justReturn(false);
|
|
Functions\when('set_transient')->justReturn(true);
|
|
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => 200]]);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
|
|
Functions\when('wp_remote_retrieve_body')->justReturn('not json');
|
|
|
|
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
|
|
|
self::assertSame($this->noUpdatePayload(), $result);
|
|
}
|
|
}
|