Files
intotheeast-com-content/plugins/api/tests/Unit/Controllers/GpmControllerUpdateAllTest.php
T

417 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\GPM\GPM;
use Grav\Plugin\Api\Controllers\GpmController;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
/**
* Unit tests for GpmController::updateAll().
*
* These tests focus on the dependency-resolution and ordering behavior added
* to the bulk-update flow. The real GPM and GpmService both touch the
* filesystem and remote GPM repository; the controller exposes
* getGpm() / installPackage() / updatePackage() as protected methods so a
* test subclass can inject mocks for these collaborators.
*
* Each test uses a fresh GPM mock per call to getGpm(); cascade-skip
* behavior is asserted via the controller's internal cascadedDeps tracking,
* not via per-iteration changes to GPM::isUpdatable() (Grav core's
* Remote\Packages static cache makes that mutate-and-recheck unreliable).
*/
#[CoversClass(GpmController::class)]
class GpmControllerUpdateAllTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_updateall_test_' . uniqid();
@mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
private function makeRequest(): ServerRequestInterface
{
$superAdmin = TestHelper::createMockUser('admin', [
'access.api.super' => true,
]);
return TestHelper::createMockRequest(
method: 'POST',
path: '/api/v1/gpm/update-all',
headers: ['Content-Type' => 'application/json'],
body: '{}',
attributes: [
'api_user' => $superAdmin,
'json_body' => [],
],
);
}
/**
* Build a controller whose getGpm/installPackage/updatePackage are
* driven by the supplied callables.
*
* @param callable():GPM $gpmFactory Returns a GPM mock per call (allows per-iteration state)
* @param callable(string,array):(string|bool) $installer Records and returns install results
* @param callable(string,array):(string|bool) $updater Records and returns update results
*/
private function createController(
callable $gpmFactory,
callable $installer,
callable $updater,
): GpmController {
$tempDir = $this->tempDir;
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
]],
]);
$locator = new class ($tempDir) {
public function __construct(private string $base) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string
{
if (str_starts_with($uri, 'cache://')) {
return $this->base . '/cache';
}
return $this->base;
}
};
$grav = TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'permissions' => new \stdClass(), // unused: super-admin shortcut bypasses resolver
]);
return new class ($grav, $config, $gpmFactory, $installer, $updater) extends GpmController {
/** @var callable():GPM */
private $gpmFactory;
/** @var callable(string,array):(string|bool) */
private $installer;
/** @var callable(string,array):(string|bool) */
private $updater;
public function __construct($grav, $config, callable $gpmFactory, callable $installer, callable $updater)
{
parent::__construct($grav, $config);
$this->gpmFactory = $gpmFactory;
$this->installer = $installer;
$this->updater = $updater;
}
protected function getGpm(bool $refresh = false): GPM
{
return ($this->gpmFactory)();
}
protected function installPackage(string $slug, array $options): string|bool
{
return ($this->installer)($slug, $options);
}
protected function updatePackage(string $slug, array $options): string|bool
{
return ($this->updater)($slug, $options);
}
};
}
/**
* Build a GPM mock with canned answers for the methods updateAll calls.
*
* @param array{plugins?: array<string,object>, themes?: array<string,object>} $updatable
* @param array<string,bool> $isUpdatable map of slug -> bool
* @param array<string,array<string,string>>|array<string,\Throwable> $depsBySlug
*/
private function makeGpmMock(array $updatable, array $isUpdatable, array $depsBySlug): GPM
{
$gpm = $this->createMock(GPM::class);
$gpm->method('getUpdatable')->willReturn($updatable);
$gpm->method('isUpdatable')->willReturnCallback(
fn (string $slug) => $isUpdatable[$slug] ?? false
);
$gpm->method('checkPackagesCanBeInstalled')->willReturnCallback(
function (array $slugs) use ($depsBySlug): void {
foreach ($slugs as $slug) {
if (($depsBySlug[$slug] ?? null) instanceof \Throwable) {
throw $depsBySlug[$slug];
}
}
}
);
$gpm->method('getDependencies')->willReturnCallback(
function (array $slugs) use ($depsBySlug): array {
$result = [];
foreach ($slugs as $slug) {
$deps = $depsBySlug[$slug] ?? [];
if ($deps instanceof \Throwable) {
throw $deps;
}
foreach ($deps as $depSlug => $action) {
$result[$depSlug] = $action;
}
}
return $result;
}
);
return $gpm;
}
private function decode(\Psr\Http\Message\ResponseInterface $response): array
{
// ApiResponse::create wraps the payload in a `data` envelope.
$body = json_decode((string) $response->getBody(), true);
return $body['data'] ?? $body;
}
// -------------------------------------------------------
// Tests
// -------------------------------------------------------
#[Test]
public function fails_package_when_grav_dep_not_satisfied(): void
{
// Two plugins updatable; "needy" requires a newer Grav, "ok" has no deps.
$gravError = new \RuntimeException(
'<red>One of the packages require Grav >=2.0.0-beta.2. Please update Grav to the latest release.'
);
$depsBySlug = [
'needy' => $gravError, // throws when getDependencies(['needy']) is called
'ok' => [],
];
$factory = function () use ($depsBySlug): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['needy' => (object) [], 'ok' => (object) []]],
isUpdatable: ['needy' => true, 'ok' => true],
depsBySlug: $depsBySlug,
);
};
$installCalls = [];
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug, array $opts) use (&$installCalls): bool {
$installCalls[] = [$slug, $opts];
return true;
},
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[] = [$slug, $opts];
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// 'needy' must NOT have been updated.
$updatedSlugs = array_column($updateCalls, 0);
$this->assertNotContains('needy', $updatedSlugs);
$this->assertContains('ok', $updatedSlugs);
$this->assertSame(['ok'], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('needy', $body['failed'][0]['package']);
// Color tags should be stripped; Grav-required language preserved.
$this->assertStringNotContainsString('<red>', $body['failed'][0]['error']);
$this->assertStringContainsString('Grav >=2.0.0-beta.2', $body['failed'][0]['error']);
}
#[Test]
public function cascades_dependency_update_before_target(): void
{
// 'parent' depends on 'child' needing an update.
// Both appear in the initial updatable list. Processing parent first
// cascade-installs child; when the loop reaches child it is found in
// the cascadedDeps set and reported as skipped.
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['parent' => (object) [], 'child' => (object) []]],
isUpdatable: ['parent' => true, 'child' => true],
depsBySlug: [
'parent' => ['child' => 'update'],
'child' => [],
],
);
};
$callOrder = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug) use (&$callOrder): bool {
$callOrder[] = "install:$slug";
return true;
},
updater: function (string $slug) use (&$callOrder): bool {
$callOrder[] = "update:$slug";
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// child must be installed BEFORE parent is updated.
$this->assertSame(['install:child', 'update:parent'], $callOrder);
$this->assertSame(['parent'], $body['updated']);
$this->assertSame(['child'], $body['cascaded_dependencies']);
// child appears in the original updatable list, but on its iteration
// the cascadedDeps set causes it to be skipped, not updated again.
$this->assertCount(1, $body['skipped']);
$this->assertSame('child', $body['skipped'][0]['package']);
$this->assertSame([], $body['failed']);
}
#[Test]
public function fails_package_when_dependency_install_throws(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['parent' => (object) []]],
isUpdatable: ['parent' => true],
depsBySlug: ['parent' => ['child' => 'install']],
);
};
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: function (string $slug): never {
throw new \RuntimeException("network error fetching $slug");
},
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[] = $slug;
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
// updatePackage must NOT be invoked when a dep install fails.
$this->assertSame([], $updateCalls);
$this->assertSame([], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('parent', $body['failed'][0]['package']);
$this->assertStringContainsString("Failed to install dependency 'child'", $body['failed'][0]['error']);
$this->assertStringContainsString('network error fetching child', $body['failed'][0]['error']);
}
#[Test]
public function passes_isTheme_option_for_theme_packages(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: [
'plugins' => ['p1' => (object) []],
'themes' => ['t1' => (object) []],
],
isUpdatable: ['p1' => true, 't1' => true],
depsBySlug: ['p1' => [], 't1' => []],
);
};
$updateCalls = [];
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: function (string $slug, array $opts) use (&$updateCalls): bool {
$updateCalls[$slug] = $opts;
return true;
},
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame(['p1', 't1'], $body['updated']);
$this->assertFalse($updateCalls['p1']['theme']);
$this->assertTrue($updateCalls['t1']['theme']);
// install_deps must be false: deps already resolved by the controller.
$this->assertFalse($updateCalls['p1']['install_deps']);
$this->assertFalse($updateCalls['t1']['install_deps']);
}
#[Test]
public function reports_update_failure_when_service_returns_non_success(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => ['boom' => (object) []]],
isUpdatable: ['boom' => true],
depsBySlug: ['boom' => []],
);
};
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: fn () => false, // service signaled failure (neither true nor string)
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame([], $body['updated']);
$this->assertCount(1, $body['failed']);
$this->assertSame('boom', $body['failed'][0]['package']);
$this->assertStringContainsString("Failed to update 'boom'", $body['failed'][0]['error']);
}
#[Test]
public function returns_empty_buckets_when_nothing_updatable(): void
{
$factory = function (): GPM {
return $this->makeGpmMock(
updatable: ['plugins' => [], 'themes' => []],
isUpdatable: [],
depsBySlug: [],
);
};
$controller = $this->createController(
gpmFactory: $factory,
installer: fn () => true,
updater: fn () => true,
);
$response = $controller->updateAll($this->makeRequest());
$body = $this->decode($response);
$this->assertSame([], $body['updated']);
$this->assertSame([], $body['failed']);
$this->assertSame([], $body['skipped']);
$this->assertSame([], $body['cascaded_dependencies']);
}
}