281 lines
9.5 KiB
PHP
281 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
|
|
|
|
use Grav\Common\Config\Config;
|
|
use Grav\Common\Page\Interfaces\PageInterface;
|
|
use Grav\Plugin\Api\Controllers\PagesController;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\Tests\Unit\TestHelper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Unit tests for the PagesController::reorganize() validation logic.
|
|
*
|
|
* Tests exercise the validation phase without performing filesystem operations.
|
|
*/
|
|
#[CoversClass(PagesController::class)]
|
|
class PagesControllerReorganizeTest extends TestCase
|
|
{
|
|
private string $tempDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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]));
|
|
}
|
|
}
|