feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<?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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user