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, themes?: array} $updatable * @param array $isUpdatable map of slug -> bool * @param array>|array $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( '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('', $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']); } }