tempDir = sys_get_temp_dir() . '/grav_api_reorg_test_' . uniqid(); @mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true); @mkdir($this->tempDir . '/pages', 0775, true); } protected function tearDown(): void { // Clean up temp dirs $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 createController(array $knownPages = [], array $configOverrides = []): PagesController { $pagesService = $this->createPagesService($knownPages); $tempDir = $this->tempDir; $config = new Config(array_merge([ 'plugins' => ['api' => [ 'batch' => ['max_items' => 50], 'route' => '/api', 'version_prefix' => 'v1', ]], ], $configOverrides)); $locator = new class ($tempDir) { public function __construct(private string $base) {} public function findResource(string $uri, bool $absolute = false): ?string { return match (true) { str_starts_with($uri, 'cache://') => $this->base . '/cache', str_starts_with($uri, 'page://') => $this->base . '/pages', default => $this->base, }; } }; $grav = TestHelper::createMockGrav([ 'pages' => $pagesService, 'config' => $config, 'locator' => $locator, ]); return new PagesController($grav, $config); } private function createPagesService(array $knownPages): object { return new class ($knownPages) { private array $pages; public function __construct(array $pages) { $this->pages = $pages; } public function enablePages(): void {} public function reset(): void {} public function find(string $route): ?object { return $this->pages[$route] ?? null; } }; } private function createMockPage(string $route, string $slug, ?int $order = null): object { $pagesDir = $this->tempDir . '/pages'; $path = $pagesDir . '/' . ($order !== null ? str_pad((string)$order, 2, '0', STR_PAD_LEFT) . '.' . $slug : $slug); return new class ($route, $slug, $order, $path) { public function __construct( private string $route, private string $slug, private ?int $order, private string $path, ) {} public function route($var = null): ?string { return $this->route; } public function slug($var = null): string { return $this->slug; } public function order($var = null): ?int { return $this->order; } public function path($var = null): ?string { return $this->path; } public function title($var = null): string { return ucfirst($this->slug); } public function isModule(): bool { return false; } public function children(): \Traversable { return new \ArrayIterator([]); } }; } private function makeRequest(array $body): \Psr\Http\Message\ServerRequestInterface { // API authority is scoped to access.api.super (admin-classic's legacy // access.admin.super is intentionally NOT honored by the API — see // AbstractApiController::isSuperAdmin()). $superAdmin = TestHelper::createMockUser('admin', [ 'access.api.super' => true, ]); return TestHelper::createMockRequest( method: 'POST', path: '/api/v1/pages/reorganize', headers: ['Content-Type' => 'application/json'], body: json_encode($body), attributes: [ 'api_user' => $superAdmin, 'json_body' => $body, ], ); } // ------------------------------------------------------- // Validation tests // ------------------------------------------------------- #[Test] public function reorganize_requires_operations_field(): void { $controller = $this->createController(); $this->expectException(ValidationException::class); $controller->reorganize($this->makeRequest([])); } #[Test] public function reorganize_rejects_empty_operations_array(): void { $controller = $this->createController(); $this->expectException(ValidationException::class); $this->expectExceptionMessage('non-empty array'); $controller->reorganize($this->makeRequest(['operations' => []])); } #[Test] public function reorganize_rejects_operation_missing_route(): void { $controller = $this->createController(); $this->expectException(ValidationException::class); $this->expectExceptionMessage('route'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['position' => 1], ], ])); } #[Test] public function reorganize_rejects_duplicate_routes(): void { $controller = $this->createController([ '/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'), '/blog' => $this->createMockPage('/blog', 'blog'), ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Duplicate'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['route' => '/blog/post-a', 'position' => 1], ['route' => '/blog/post-a', 'position' => 2], ], ])); } #[Test] public function reorganize_rejects_nonexistent_page(): void { $controller = $this->createController(); $this->expectException(ValidationException::class); $this->expectExceptionMessage('not found'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['route' => '/does-not-exist', 'position' => 1], ], ])); } #[Test] public function reorganize_rejects_nonexistent_parent(): void { $controller = $this->createController([ '/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'), '/blog' => $this->createMockPage('/blog', 'blog'), ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Destination parent not found'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['route' => '/blog/post-a', 'parent' => '/nonexistent', 'position' => 1], ], ])); } #[Test] public function reorganize_rejects_move_into_own_subtree(): void { $controller = $this->createController([ '/blog' => $this->createMockPage('/blog', 'blog'), '/blog/child' => $this->createMockPage('/blog/child', 'child'), ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('own subtree'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['route' => '/blog', 'parent' => '/blog/child'], ], ])); } #[Test] public function reorganize_rejects_position_conflict(): void { $controller = $this->createController([ '/blog/post-a' => $this->createMockPage('/blog/post-a', 'post-a'), '/blog/post-b' => $this->createMockPage('/blog/post-b', 'post-b'), '/blog' => $this->createMockPage('/blog', 'blog'), ]); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Position conflict'); $controller->reorganize($this->makeRequest([ 'operations' => [ ['route' => '/blog/post-a', 'position' => 1], ['route' => '/blog/post-b', 'position' => 1], ], ])); } #[Test] public function reorganize_rejects_exceeding_batch_limit(): void { $pages = []; $ops = []; for ($i = 0; $i < 51; $i++) { $route = "/page-{$i}"; $pages[$route] = $this->createMockPage($route, "page-{$i}"); $ops[] = ['route' => $route, 'position' => $i + 1]; } $controller = $this->createController($pages); $this->expectException(ValidationException::class); $this->expectExceptionMessage('limited to'); $controller->reorganize($this->makeRequest(['operations' => $ops])); } }