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-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
|
||||
(„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
|
||||
@@ -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/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`,
|
||||
`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.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
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\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||
@@ -98,7 +99,7 @@ final class DiviIntegration implements Integration
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||||
$loader = $this->loader();
|
||||
$composition = new DiviComposition($this->modules, $this->design);
|
||||
$writer = new DiviWriter(
|
||||
$loader,
|
||||
@@ -122,4 +123,15 @@ final class DiviIntegration implements Integration
|
||||
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 WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||
new MediaIntegration($schema, $changeLog, $limits),
|
||||
$divi
|
||||
$divi,
|
||||
$divi->library()
|
||||
);
|
||||
|
||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
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
|
||||
{
|
||||
$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