feat: group Divi-Bibliothek with lit/library-list
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
|
|||||||
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
||||||
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
||||||
|
|
||||||
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter
|
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter
|
||||||
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
||||||
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
||||||
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
|
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
|
||||||
@@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
|
|||||||
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
||||||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||||||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
||||||
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`).
|
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`).
|
||||||
|
|
||||||
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
|||||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||||
@@ -98,7 +99,7 @@ final class DiviIntegration implements Integration
|
|||||||
|
|
||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
{
|
{
|
||||||
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
|
$loader = $this->loader();
|
||||||
$composition = new DiviComposition($this->modules, $this->design);
|
$composition = new DiviComposition($this->modules, $this->design);
|
||||||
$writer = new DiviWriter(
|
$writer = new DiviWriter(
|
||||||
$loader,
|
$loader,
|
||||||
@@ -122,4 +123,15 @@ final class DiviIntegration implements Integration
|
|||||||
new DiviReplaceTextAbility($this->schema, $loader, $writer),
|
new DiviReplaceTextAbility($this->schema, $loader, $writer),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
|
||||||
|
public function library(): LibraryIntegration
|
||||||
|
{
|
||||||
|
return new LibraryIntegration($this->access, $this->loader(), $this->design);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loader(): DiviPageLoader
|
||||||
|
{
|
||||||
|
return new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group "Divi-Bibliothek" (E9): lit/library-list, later lit/library-save and lit-library-insert.
|
||||||
|
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
||||||
|
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
||||||
|
* Divi 5 builder, like the Divi group.
|
||||||
|
*/
|
||||||
|
final class LibraryIntegration implements Integration
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DesignSystemReader $design
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return LibraryAccess::GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Divi-Bibliothek', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return DiviModules::isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
|
||||||
|
* of published entries (filters, cursor); with id one entry with its node outline, tree_rev and,
|
||||||
|
* for a global entry, the pages that show it. Rights: LibraryAccess (edit_posts + divi_library;
|
||||||
|
* every listed entry also edit_post – others are left out, without title or count). With id,
|
||||||
|
* everything but a library entry the user may read answers like a missing ID (ruling P12).
|
||||||
|
*/
|
||||||
|
final class LibraryListAbility implements Ability
|
||||||
|
{
|
||||||
|
/** At most this many entries are looked at per call while skipping entries the user may not edit. */
|
||||||
|
public const MAX_SCAN = 500;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LibraryAccess $access,
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DesignSystemReader $design,
|
||||||
|
private GlobalUsages $usages
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/library-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Divi-Bibliothek auflisten';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
|
||||||
|
. 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
|
||||||
|
. 'im Detail – Knoten-Übersicht, tree_rev und bei globalen Einträgen die Seiten, die ihn zeigen '
|
||||||
|
. '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => array_merge([
|
||||||
|
'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'],
|
||||||
|
'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES],
|
||||||
|
'scope' => ['type' => 'string', 'enum' => ['normal', 'global']],
|
||||||
|
'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'],
|
||||||
|
'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'],
|
||||||
|
], Cursor::inputProperties()),
|
||||||
|
'additionalProperties' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputSchema(): ?array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'items' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'title' => ['type' => 'string'],
|
||||||
|
'layout_type' => ['type' => ['string', 'null']],
|
||||||
|
'scope' => ['type' => 'string'],
|
||||||
|
'module_type' => ['type' => ['string', 'null']],
|
||||||
|
'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
'modified' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
'required' => [
|
||||||
|
'id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'next_cursor' => ['type' => ['string', 'null']],
|
||||||
|
'outline' => ['type' => 'string'],
|
||||||
|
'tree_rev' => ['type' => 'string'],
|
||||||
|
'rev' => ['type' => 'string'],
|
||||||
|
'edit_url' => ['type' => 'string'],
|
||||||
|
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'affected_count' => ['type' => 'integer'],
|
||||||
|
],
|
||||||
|
'required' => ['items', 'next_cursor'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annotations(): array
|
||||||
|
{
|
||||||
|
return Annotations::read();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDirect(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
if (isset($input['id'])) {
|
||||||
|
$page = $this->entry($input['id']);
|
||||||
|
|
||||||
|
return $page instanceof WP_Error ? $page : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->access->canList() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
if (isset($input['id'])) {
|
||||||
|
$page = $this->entry($input['id']);
|
||||||
|
|
||||||
|
return $page instanceof WP_Error ? $page : $this->detail($page);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refusal = $this->access->canList();
|
||||||
|
if ($refusal !== null) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||||||
|
if ($offset === null) {
|
||||||
|
return Cursor::invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter(
|
||||||
|
[
|
||||||
|
'layout_type' => $input['layout_type'] ?? null,
|
||||||
|
'scope' => $input['scope'] ?? null,
|
||||||
|
'search' => $input['search'] ?? null,
|
||||||
|
'category' => $input['category'] ?? null,
|
||||||
|
],
|
||||||
|
static fn (mixed $value): bool => is_string($value) && $value !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the entries the user may edit. The cursor counts the store's rows, so entries
|
||||||
|
* left out never shift a page; next_cursor points at the next readable entry, null when none
|
||||||
|
* follows (or the scan cap is reached with nothing left).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $filters
|
||||||
|
* @return array{items: list<array<string, mixed>>, next_cursor: ?string}
|
||||||
|
*/
|
||||||
|
private function page(array $filters, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$store = $this->access->store();
|
||||||
|
$items = [];
|
||||||
|
$position = $offset;
|
||||||
|
while ($position - $offset < self::MAX_SCAN) {
|
||||||
|
$rows = $store->find($filters, $limit, $position);
|
||||||
|
foreach ($rows as $post) {
|
||||||
|
if ($this->access->canRead($post) !== null) {
|
||||||
|
++$position;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count($items) === $limit) {
|
||||||
|
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
|
||||||
|
}
|
||||||
|
$items[] = $store->describe($post);
|
||||||
|
++$position;
|
||||||
|
}
|
||||||
|
if (count($rows) <= $limit) {
|
||||||
|
return ['items' => $items, 'next_cursor' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The library entry $id loaded for reading. Anything that is not a library entry reads like a
|
||||||
|
* missing ID, so the tool tells nothing about other posts (ruling P12).
|
||||||
|
*/
|
||||||
|
private function entry(mixed $id): DiviPage|WP_Error
|
||||||
|
{
|
||||||
|
$postId = PostGuard::id($id);
|
||||||
|
if ($postId === null) {
|
||||||
|
return Errors::invalidInput('id ist keine gültige Eintrags-ID.', ['id' => 'positive Ganzzahl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = $this->access->item($postId, false);
|
||||||
|
if (! $item instanceof WP_Post) {
|
||||||
|
return $item ?? PostGuard::notFound($postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->loader->load($postId, false);
|
||||||
|
if ($page instanceof WP_Error) {
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page->isLibraryItem() ? $page : PostGuard::notFound($postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function detail(DiviPage $page): array
|
||||||
|
{
|
||||||
|
$item = $this->access->store()->describe($page->post);
|
||||||
|
$outline = new Outline($this->design->read()->presetNames());
|
||||||
|
$result = [
|
||||||
|
'items' => [$item],
|
||||||
|
'next_cursor' => null,
|
||||||
|
'outline' => $outline->text($outline->rows($page->index)),
|
||||||
|
'tree_rev' => $page->treeRev(),
|
||||||
|
'rev' => Rev::ofPost($page->post),
|
||||||
|
'edit_url' => $page->editUrl(),
|
||||||
|
];
|
||||||
|
if ($item['scope'] === 'global') {
|
||||||
|
$usages = $this->usages->of((int) $page->post->ID);
|
||||||
|
$result['affected'] = $usages['items'];
|
||||||
|
$result['affected_count'] = $usages['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -94,7 +94,8 @@ final class Plugin
|
|||||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||||
new MediaIntegration($schema, $changeLog, $limits),
|
new MediaIntegration($schema, $changeLog, $limits),
|
||||||
$divi
|
$divi,
|
||||||
|
$divi->library()
|
||||||
);
|
);
|
||||||
|
|
||||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
@@ -48,6 +49,24 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testProvidesTheLibraryGroup(): void
|
||||||
|
{
|
||||||
|
Functions\when('__')->returnArg();
|
||||||
|
$library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library();
|
||||||
|
|
||||||
|
self::assertSame('divi-library', $library->id());
|
||||||
|
self::assertSame('Divi-Bibliothek', $library->label());
|
||||||
|
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
||||||
|
self::assertSame(
|
||||||
|
['lit/library-list'],
|
||||||
|
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||||||
|
);
|
||||||
|
foreach ($library->abilities() as $ability) {
|
||||||
|
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
|
||||||
|
self::assertNotNull($ability->outputSchema(), $ability->name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testGivesContentCreateTheDiviShortForm(): void
|
public function testGivesContentCreateTheDiviShortForm(): void
|
||||||
{
|
{
|
||||||
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryListAbility;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
final class LibraryListAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when('get_the_terms')->justReturn(false);
|
||||||
|
Functions\when('get_post_meta')->justReturn('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $deniedRoles
|
||||||
|
* @param list<int> $ids Entries the store lists (in this order).
|
||||||
|
*/
|
||||||
|
private function ability(array $deniedRoles = [], array $ids = [1055, 1058, 1059]): LibraryListAbility
|
||||||
|
{
|
||||||
|
$store = LibraryFixtures::store();
|
||||||
|
$store->allows('find')->andReturnUsing(static fn (array $filters, int $limit, int $offset): array => array_slice(
|
||||||
|
array_map(static fn (int $id) => LibraryFixtures::item($id), $ids),
|
||||||
|
$offset,
|
||||||
|
$limit + 1
|
||||||
|
));
|
||||||
|
$access = LibraryFixtures::access($deniedRoles, true, $store);
|
||||||
|
$usages = Mockery::mock(GlobalUsages::class);
|
||||||
|
$usages->allows('of')->with(1058)->andReturn([
|
||||||
|
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']],
|
||||||
|
'count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user with every capability but edit_post on the entries $hidden. */
|
||||||
|
private static function withoutEditPostOn(array $hidden): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array((int) ($args[0] ?? 0), $hidden, true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListsAPageWithCursor(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
|
||||||
|
$first = $this->ability()->execute(['limit' => 2]);
|
||||||
|
$second = $this->ability()->execute(['limit' => 2, 'cursor' => $first['next_cursor']]);
|
||||||
|
|
||||||
|
self::assertSame([1055, 1058], array_column($first['items'], 'id'));
|
||||||
|
self::assertSame(['normal', 'global'], array_column($first['items'], 'scope'));
|
||||||
|
self::assertSame('section', $first['items'][0]['layout_type']);
|
||||||
|
self::assertSame(Cursor::encode(2), $first['next_cursor']);
|
||||||
|
self::assertSame([1059], array_column($second['items'], 'id'));
|
||||||
|
self::assertNull($second['next_cursor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPassesTheFilters(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
$store = LibraryFixtures::store();
|
||||||
|
$store->expects('find')->with(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten'], 20, 0)
|
||||||
|
->andReturn([]);
|
||||||
|
$access = LibraryFixtures::access([], true, $store);
|
||||||
|
$ability = new LibraryListAbility(
|
||||||
|
$access,
|
||||||
|
LibraryFixtures::loader($access),
|
||||||
|
DiviWriterFixture::designReader(),
|
||||||
|
Mockery::mock(GlobalUsages::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $ability->execute(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten', 'category' => '']);
|
||||||
|
|
||||||
|
self::assertSame(['items' => [], 'next_cursor' => null], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDetailOfAGlobalEntryWithOutlineAndAffectedPages(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute(['id' => 1058]);
|
||||||
|
|
||||||
|
self::assertSame(1058, $result['items'][0]['id']);
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
self::assertStringStartsWith('s0 section "Global Abschnitt"', $result['outline']);
|
||||||
|
self::assertSame(12, strlen($result['tree_rev']));
|
||||||
|
self::assertSame(1, $result['affected_count']);
|
||||||
|
self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $result['edit_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDetailOfSomethingElseIsNotFoundAndTheRoleIsNeeded(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([LibraryFixtures::page('')]);
|
||||||
|
|
||||||
|
self::assertToolError($this->ability()->execute(['id' => LibraryFixtures::PAGE]), 'not_found');
|
||||||
|
self::assertToolError($this->ability(['divi_library'])->permission([]), 'forbidden');
|
||||||
|
self::assertToolError($this->ability()->execute(['cursor' => '!!']), 'validation_failed');
|
||||||
|
self::assertTrue($this->ability()->annotations()['readonly']);
|
||||||
|
self::assertFalse($this->ability()->isDirect());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output ruling: entries without edit_post are left out – no title, no count, the cursor still pages right. */
|
||||||
|
public function testLeavesOutEntriesTheUserMayNotEdit(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
self::withoutEditPostOn([1055, 1057]);
|
||||||
|
$ids = [1054, 1055, 1056, 1057, 1058, 1059];
|
||||||
|
|
||||||
|
$first = $this->ability([], $ids)->execute(['limit' => 2]);
|
||||||
|
$second = $this->ability([], $ids)->execute(['limit' => 2, 'cursor' => $first['next_cursor']]);
|
||||||
|
|
||||||
|
self::assertSame([1054, 1056], array_column($first['items'], 'id'));
|
||||||
|
self::assertNotNull($first['next_cursor']);
|
||||||
|
self::assertSame([1058, 1059], array_column($second['items'], 'id'));
|
||||||
|
self::assertNull($second['next_cursor']);
|
||||||
|
$json = (string) json_encode([$first, $second]);
|
||||||
|
self::assertStringNotContainsString('[Fixture] 1055', $json);
|
||||||
|
self::assertStringNotContainsString('[Fixture] 1057', $json);
|
||||||
|
self::assertSame(['items', 'next_cursor'], array_keys($first));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
self::withoutEditPostOn([1058, 1059]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute(['limit' => 1]);
|
||||||
|
|
||||||
|
self::assertSame([1055], array_column($result['items'], 'id'));
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P12: without the library right, an id reads exactly like a missing ID; the list says forbidden. */
|
||||||
|
public function testWithoutTheLibraryRightAnIdIsNotFoundLikeAMissingOne(): void
|
||||||
|
{
|
||||||
|
$missing = PostGuard::notFound(1058);
|
||||||
|
$cases = [
|
||||||
|
'no divi_library' => [[], ['divi_library']],
|
||||||
|
'no edit_posts' => [['edit_posts'], []],
|
||||||
|
];
|
||||||
|
foreach ($cases as $case => [$deniedCaps, $deniedRoles]) {
|
||||||
|
LibraryFixtures::given([LibraryFixtures::item(1058), LibraryFixtures::page('')], $deniedCaps);
|
||||||
|
$ability = $this->ability($deniedRoles);
|
||||||
|
foreach ([$ability->permission(['id' => 1058]), $ability->execute(['id' => 1058])] as $result) {
|
||||||
|
self::assertInstanceOf(WP_Error::class, $result, $case);
|
||||||
|
self::assertSame($missing->get_error_code(), $result->get_error_code(), $case);
|
||||||
|
self::assertSame($missing->get_error_message(), $result->get_error_message(), $case);
|
||||||
|
self::assertSame($missing->get_error_data(), $result->get_error_data(), $case);
|
||||||
|
}
|
||||||
|
self::assertToolError($ability->execute(['id' => 99999]), 'not_found');
|
||||||
|
self::assertToolError($ability->execute(['id' => LibraryFixtures::PAGE]), 'not_found');
|
||||||
|
self::assertToolError($ability->execute([]), 'forbidden');
|
||||||
|
self::assertToolError($ability->permission([]), 'forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnInvalidIdIsAValidationError(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
|
||||||
|
self::assertToolError($this->ability()->execute(['id' => 'abc']), 'validation_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDetailWithoutEditPostIsForbiddenWithoutTheTitle(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([LibraryFixtures::item(1058, ['post_title' => 'Geheim'])]);
|
||||||
|
self::withoutEditPostOn([1058]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute(['id' => 1058]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'forbidden');
|
||||||
|
self::assertStringNotContainsString('Geheim', (string) json_encode([$result->get_error_message(), $result->get_error_data()]));
|
||||||
|
self::assertToolError($this->ability()->permission(['id' => 1058]), 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDetailOfANormalEntryHasNoAffectedPages(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([LibraryFixtures::item(1055)]);
|
||||||
|
|
||||||
|
$result = $this->ability()->execute(['id' => 1055]);
|
||||||
|
|
||||||
|
self::assertSame('normal', $result['items'][0]['scope']);
|
||||||
|
self::assertArrayNotHasKey('affected', $result);
|
||||||
|
self::assertTrue($this->ability()->permission(['id' => 1055]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user