feat: content-query and content-get tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Error;
|
||||
|
||||
final class ContentGetAbility implements Ability
|
||||
{
|
||||
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/content-get';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Inhalt lesen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Liest einen Eintrag vollständig: Kerndaten, Inhalt, Begriffe, alle Felder (Pods/Meta), '
|
||||
. 'Beitragsbild, rev (für lit-content-update). Bei Divi-Inhalten wird content nicht ausgegeben; '
|
||||
. 'hints verweist dann auf lit-divi-outline.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['post_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return PostPresenter::fullSchema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
||||
|
||||
return $post instanceof WP_Error ? $post : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
||||
if ($post instanceof WP_Error) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
return $this->presenter->full($post);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
final class ContentQueryAbility implements Ability
|
||||
{
|
||||
private const STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
|
||||
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
|
||||
|
||||
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/content-query';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Inhalte suchen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Sucht Einträge eines Inhaltstyps. Filter: Status, Volltext, Taxonomie-Begriffe (IDs oder Slugs), '
|
||||
. 'Feldwert, Datum, übergeordneter Eintrag; sortierbar; Cursor-Paginierung. Liefert Kurzdaten '
|
||||
. '(ID, Titel, Status, URLs, Divi ja/nein) – Details über lit-content-get.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge([
|
||||
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'],
|
||||
'status' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string', 'enum' => self::STATUSES],
|
||||
'description' => 'Standard: alle außer Papierkorb.',
|
||||
],
|
||||
'search' => ['type' => 'string'],
|
||||
'taxonomy' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'taxonomy' => ['type' => 'string'],
|
||||
'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]],
|
||||
],
|
||||
'required' => ['taxonomy', 'terms'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
'field' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'key' => ['type' => 'string'],
|
||||
'value' => ['type' => ['string', 'number', 'boolean']],
|
||||
'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']],
|
||||
],
|
||||
'required' => ['key'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||||
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||||
'parent' => ['type' => 'integer', 'minimum' => 0],
|
||||
'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']],
|
||||
'order' => ['type' => 'string', 'enum' => ['asc', 'desc']],
|
||||
], Cursor::inputProperties()),
|
||||
'required' => ['post_type'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()],
|
||||
'next_cursor' => ['type' => ['string', 'null']],
|
||||
],
|
||||
'required' => ['items', 'next_cursor'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||||
|
||||
return $type instanceof WP_Error ? $type : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
|
||||
if ($offset === null) {
|
||||
return Cursor::invalid();
|
||||
}
|
||||
|
||||
$limit = Cursor::limit($input['limit'] ?? null);
|
||||
$posts = get_posts(array_merge($this->queryArgs($input), [
|
||||
'posts_per_page' => $limit + 1,
|
||||
'offset' => $offset,
|
||||
]));
|
||||
$page = Cursor::page(array_values($posts), $limit, $offset);
|
||||
|
||||
$items = [];
|
||||
foreach ($page['items'] as $post) {
|
||||
if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) {
|
||||
$items[] = $this->presenter->summary($post);
|
||||
}
|
||||
}
|
||||
|
||||
return ['items' => $items, 'next_cursor' => $page['next_cursor']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function queryArgs(array $input): array
|
||||
{
|
||||
$args = [
|
||||
'post_type' => (string) $input['post_type'],
|
||||
'post_status' => $input['status'] ?? self::STATUSES,
|
||||
'orderby' => (string) ($input['orderby'] ?? 'date'),
|
||||
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
|
||||
'no_found_rows' => true,
|
||||
'ignore_sticky_posts' => true,
|
||||
'suppress_filters' => false,
|
||||
];
|
||||
|
||||
if (isset($input['search']) && $input['search'] !== '') {
|
||||
$args['s'] = (string) $input['search'];
|
||||
}
|
||||
|
||||
if (isset($input['parent'])) {
|
||||
$args['post_parent'] = (int) $input['parent'];
|
||||
}
|
||||
|
||||
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
|
||||
$args['tax_query'] = $this->taxQuery($input['taxonomy']);
|
||||
}
|
||||
|
||||
if (isset($input['field']) && is_array($input['field'])) {
|
||||
$compare = (string) ($input['field']['compare'] ?? '=');
|
||||
$clause = ['key' => (string) $input['field']['key'], 'compare' => $compare];
|
||||
if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) {
|
||||
$clause['value'] = $input['field']['value'] ?? '';
|
||||
}
|
||||
$args['meta_query'] = [$clause];
|
||||
}
|
||||
|
||||
$date = [];
|
||||
if (isset($input['date_after'])) {
|
||||
$date['after'] = (string) $input['date_after'];
|
||||
}
|
||||
if (isset($input['date_before'])) {
|
||||
$date['before'] = (string) $input['date_before'];
|
||||
}
|
||||
if ($date !== []) {
|
||||
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filter
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private function taxQuery(array $filter): array
|
||||
{
|
||||
$taxonomy = (string) $filter['taxonomy'];
|
||||
$terms = (array) $filter['terms'];
|
||||
$ids = array_values(array_filter($terms, 'is_int'));
|
||||
$slugs = array_values(array_filter($terms, 'is_string'));
|
||||
|
||||
$clauses = [];
|
||||
if ($ids !== []) {
|
||||
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids];
|
||||
}
|
||||
if ($slugs !== []) {
|
||||
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs];
|
||||
}
|
||||
|
||||
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
|
||||
}
|
||||
}
|
||||
@@ -69,16 +69,30 @@ final class ContentTypesAbility implements Ability
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_posts')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
|
||||
if (! current_user_can('edit_posts')) {
|
||||
return Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
|
||||
}
|
||||
|
||||
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
|
||||
|
||||
return $this->forbiddenForType($postType) ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
|
||||
if ($postType === '') {
|
||||
return ['post_types' => $this->schema->describePostTypes()];
|
||||
$types = array_values(array_filter(
|
||||
$this->schema->describePostTypes(),
|
||||
fn (array $description): bool => $this->canEdit((string) $description['name'])
|
||||
));
|
||||
|
||||
return ['post_types' => $types];
|
||||
}
|
||||
|
||||
$forbidden = $this->forbiddenForType($postType);
|
||||
if ($forbidden !== null) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
$description = $this->schema->describe($postType, true);
|
||||
@@ -91,4 +105,27 @@ final class ContentTypesAbility implements Ability
|
||||
|
||||
return ['post_types' => [$description]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only post types the user can edit are listed or described (spec §4.5): requesting
|
||||
* a managed but non-editable type is a forbidden error, not a silent omission.
|
||||
*/
|
||||
private function forbiddenForType(string $postType): ?WP_Error
|
||||
{
|
||||
if ($postType !== '' && $this->schema->isManaged($postType) && ! $this->canEdit($postType)) {
|
||||
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $postType));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function canEdit(string $postType): bool
|
||||
{
|
||||
$object = get_post_type_object($postType);
|
||||
$capability = is_object($object) && isset($object->cap->edit_posts)
|
||||
? (string) $object->cap->edit_posts
|
||||
: 'edit_posts';
|
||||
|
||||
return current_user_can($capability);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use LIT\MCP\Abilities\JsonMap;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Output format of an entry for the content tools. Not final so abilities can be
|
||||
* unit-tested with a mock.
|
||||
*/
|
||||
class PostPresenter
|
||||
{
|
||||
public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut. Die Divi-Struktur wird über lit-divi-outline '
|
||||
. 'gelesen und mit den lit-divi-*-Tools bearbeitet; fehlen diese Tools, kann der Divi-Inhalt hier nicht '
|
||||
. 'bearbeitet werden. content wird deshalb nicht ausgegeben; Titel, Auszug, Felder und Begriffe sind '
|
||||
. 'über lit-content-update änderbar.';
|
||||
|
||||
public function __construct(private ContentSchema $schema)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function summary(WP_Post $post): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $post->ID,
|
||||
'type' => (string) $post->post_type,
|
||||
'status' => (string) $post->post_status,
|
||||
'title' => (string) $post->post_title,
|
||||
'slug' => (string) $post->post_name,
|
||||
'parent' => (int) $post->post_parent,
|
||||
'date' => (string) $post->post_date,
|
||||
'modified' => (string) $post->post_modified,
|
||||
'url' => (string) get_permalink($post),
|
||||
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
|
||||
'divi' => self::usesDivi($post),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function full(WP_Post $post): array
|
||||
{
|
||||
$divi = self::usesDivi($post);
|
||||
|
||||
return array_merge($this->summary($post), [
|
||||
'excerpt' => (string) $post->post_excerpt,
|
||||
'content' => $divi ? null : (string) $post->post_content,
|
||||
'menu_order' => (int) $post->menu_order,
|
||||
'author' => [
|
||||
'id' => (int) $post->post_author,
|
||||
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
|
||||
],
|
||||
'template' => (string) get_page_template_slug($post),
|
||||
'preview_url' => (string) get_preview_post_link($post),
|
||||
'terms' => JsonMap::of($this->terms($post)),
|
||||
'fields' => JsonMap::of($this->fields($post)),
|
||||
'featured_image' => $this->featuredImage($post),
|
||||
'rev' => Rev::of((string) $post->post_content),
|
||||
'hints' => $divi ? [self::DIVI_HINT] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function usesDivi(WP_Post $post): bool
|
||||
{
|
||||
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
|
||||
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function summarySchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer'],
|
||||
'type' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
'title' => ['type' => 'string'],
|
||||
'slug' => ['type' => 'string'],
|
||||
'parent' => ['type' => 'integer'],
|
||||
'date' => ['type' => 'string'],
|
||||
'modified' => ['type' => 'string'],
|
||||
'url' => ['type' => 'string'],
|
||||
'edit_url' => ['type' => 'string'],
|
||||
'divi' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function fullSchema(): array
|
||||
{
|
||||
$schema = self::summarySchema();
|
||||
$schema['properties'] = array_merge($schema['properties'], [
|
||||
'excerpt' => ['type' => 'string'],
|
||||
'content' => ['type' => ['string', 'null']],
|
||||
'menu_order' => ['type' => 'integer'],
|
||||
'author' => ['type' => 'object'],
|
||||
'template' => ['type' => 'string'],
|
||||
'preview_url' => ['type' => 'string'],
|
||||
'terms' => ['type' => 'object'],
|
||||
'fields' => ['type' => 'object'],
|
||||
'featured_image' => ['type' => ['object', 'null']],
|
||||
'rev' => ['type' => 'string'],
|
||||
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
]);
|
||||
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
|
||||
private function terms(WP_Post $post): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
|
||||
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
|
||||
if (! is_array($terms)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$taxonomy['name']] = array_map(
|
||||
static fn (object $term): array => [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
'slug' => (string) $term->slug,
|
||||
],
|
||||
$terms
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function fields(WP_Post $post): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
|
||||
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{id: int, url: string, alt: string}|null */
|
||||
private function featuredImage(WP_Post $post): ?array
|
||||
{
|
||||
$id = (int) get_post_thumbnail_id($post);
|
||||
if ($id === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'url' => (string) wp_get_attachment_url($id),
|
||||
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ namespace LIT\MCP\Integrations\WordPress;
|
||||
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
|
||||
/**
|
||||
@@ -31,8 +34,12 @@ final class WordPressIntegration implements Integration
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
$presenter = new PostPresenter($this->schema);
|
||||
|
||||
return [
|
||||
new ContentTypesAbility($this->schema),
|
||||
new ContentQueryAbility($this->schema, $presenter),
|
||||
new ContentGetAbility($this->schema, $presenter),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class ContentGetAbilityTest extends TestCase
|
||||
{
|
||||
public function testReturnsThePresentedPost(): void
|
||||
{
|
||||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
|
||||
Functions\when('get_post')->justReturn($post);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
$presenter = Mockery::mock(PostPresenter::class);
|
||||
$presenter->expects('full')->with($post)->andReturn(['id' => 5]);
|
||||
$ability = new ContentGetAbility($schema, $presenter);
|
||||
|
||||
self::assertTrue($ability->permission(['post_id' => 5]));
|
||||
self::assertSame(['id' => 5], $ability->execute(['post_id' => 5]));
|
||||
}
|
||||
|
||||
public function testMissingPostIsNotFound(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class));
|
||||
|
||||
self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code());
|
||||
}
|
||||
|
||||
public function testOutputSchemaAllowsNullContentForDivi(): void
|
||||
{
|
||||
$ability = new ContentGetAbility(Mockery::mock(ContentSchema::class), Mockery::mock(PostPresenter::class));
|
||||
|
||||
self::assertSame(['string', 'null'], $ability->outputSchema()['properties']['content']['type']);
|
||||
self::assertTrue($ability->isDirect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class ContentQueryAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(): ContentQueryAbility
|
||||
{
|
||||
$presenter = Mockery::mock(PostPresenter::class);
|
||||
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]);
|
||||
|
||||
return new ContentQueryAbility(Mockery::mock(ContentSchema::class), $presenter);
|
||||
}
|
||||
|
||||
public function testBuildsTheQueryAndPaginates(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool {
|
||||
return $args['post_type'] === 'lit_person'
|
||||
&& $args['post_status'] === ['draft']
|
||||
&& $args['s'] === 'Anna'
|
||||
&& $args['posts_per_page'] === 3
|
||||
&& $args['offset'] === 4
|
||||
&& $args['orderby'] === 'title'
|
||||
&& $args['order'] === 'ASC'
|
||||
&& $args['tax_query'] === [
|
||||
'relation' => 'OR',
|
||||
['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]],
|
||||
['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']],
|
||||
]
|
||||
&& $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']]
|
||||
&& $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]];
|
||||
}))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]);
|
||||
|
||||
$result = $this->ability()->execute([
|
||||
'post_type' => 'lit_person',
|
||||
'status' => ['draft'],
|
||||
'search' => 'Anna',
|
||||
'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']],
|
||||
'field' => ['key' => 'level', 'value' => 'senior'],
|
||||
'date_after' => '2026-01-01',
|
||||
'orderby' => 'title',
|
||||
'order' => 'asc',
|
||||
'limit' => 2,
|
||||
'cursor' => Cursor::encode(4),
|
||||
]);
|
||||
|
||||
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
|
||||
self::assertSame(6, Cursor::decode($result['next_cursor']));
|
||||
}
|
||||
|
||||
public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\expect('get_posts')->once()->with(Mockery::on(
|
||||
static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']]
|
||||
&& $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future']
|
||||
))->andReturn([new WP_Post(['ID' => 1])]);
|
||||
|
||||
$result = $this->ability()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
|
||||
|
||||
self::assertNull($result['next_cursor']);
|
||||
}
|
||||
|
||||
public function testPostsTheUserCannotEditAreDropped(): void
|
||||
{
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
|
||||
Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]);
|
||||
|
||||
$result = $this->ability()->execute(['post_type' => 'page']);
|
||||
|
||||
self::assertSame([['id' => 1]], $result['items']);
|
||||
}
|
||||
|
||||
public function testInvalidCursorIsRejected(): void
|
||||
{
|
||||
$result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']);
|
||||
|
||||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testPermissionNeedsAManagedTypeAndEditRights(): void
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(false);
|
||||
$ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class));
|
||||
|
||||
self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
{
|
||||
public function testListsAllTypesWithoutFields(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_posts']]);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->expects('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]);
|
||||
|
||||
@@ -23,9 +25,26 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
self::assertSame(['post_types' => [['name' => 'page'], ['name' => 'post']]], $result);
|
||||
}
|
||||
|
||||
public function testTypesTheUserCannotEditAreOmittedFromTheList(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->alias(static fn (string $type) => (object) [
|
||||
'cap' => (object) ['edit_posts' => $type === 'page' ? 'edit_pages' : 'edit_posts'],
|
||||
]);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_posts');
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]);
|
||||
|
||||
$result = (new ContentTypesAbility($schema))->execute([]);
|
||||
|
||||
self::assertSame(['post_types' => [['name' => 'post']]], $result);
|
||||
}
|
||||
|
||||
public function testSingleTypeIncludesFields(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_posts']]);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
$schema->expects('describe')->with('lit_person', true)->andReturn(['name' => 'lit_person', 'fields' => []]);
|
||||
|
||||
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'lit_person']);
|
||||
@@ -36,6 +55,7 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(false);
|
||||
$schema->allows('describe')->andReturn(null);
|
||||
$schema->allows('postTypeNames')->andReturn(['page', 'post']);
|
||||
|
||||
@@ -46,6 +66,19 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
self::assertStringContainsString('page, post', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testRequestingANonEditableSpecificTypeIsForbidden(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages']]);
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'page']);
|
||||
|
||||
self::assertInstanceOf(WP_Error::class, $result);
|
||||
self::assertSame('lit_forbidden', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testPermissionRequiresEditPosts(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
@@ -54,6 +87,29 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
|
||||
}
|
||||
|
||||
public function testPermissionForbidsANonEditableSpecificType(): void
|
||||
{
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_posts');
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages']]);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
$result = (new ContentTypesAbility($schema))->permission(['post_type' => 'lit_person']);
|
||||
|
||||
self::assertInstanceOf(WP_Error::class, $result);
|
||||
self::assertSame('lit_forbidden', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testPermissionAllowsAnEditableSpecificType(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_posts']]);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(true);
|
||||
|
||||
self::assertTrue((new ContentTypesAbility($schema))->permission(['post_type' => 'page']));
|
||||
}
|
||||
|
||||
public function testMetadata(): void
|
||||
{
|
||||
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use stdClass;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
final class PostPresenterTest extends TestCase
|
||||
{
|
||||
private function stubLinks(): void
|
||||
{
|
||||
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
|
||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
|
||||
Functions\when('get_the_author_meta')->justReturn('Jan');
|
||||
Functions\when('get_page_template_slug')->justReturn('');
|
||||
}
|
||||
|
||||
public function testSummaryContainsCoreDataAndUrls(): void
|
||||
{
|
||||
$this->stubLinks();
|
||||
Functions\when('get_post_meta')->justReturn('');
|
||||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_name' => 'team']);
|
||||
|
||||
$summary = (new PostPresenter(Mockery::mock(ContentSchema::class)))->summary($post);
|
||||
|
||||
self::assertSame(5, $summary['id']);
|
||||
self::assertSame('Team', $summary['title']);
|
||||
self::assertSame('team', $summary['slug']);
|
||||
self::assertSame('https://kunde.de/team/', $summary['url']);
|
||||
self::assertFalse($summary['divi']);
|
||||
}
|
||||
|
||||
public function testFullReadsTermsFieldsAndFeaturedImage(): void
|
||||
{
|
||||
$this->stubLinks();
|
||||
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => match ($key) {
|
||||
'_wp_attachment_image_alt' => 'Porträt',
|
||||
default => '',
|
||||
});
|
||||
Functions\when('wp_get_post_terms')->justReturn([new WP_Term(['term_id' => 4, 'name' => 'News', 'slug' => 'news'])]);
|
||||
Functions\when('get_post_thumbnail_id')->justReturn(33);
|
||||
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/foto.jpg');
|
||||
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
|
||||
$source = Mockery::mock(FieldSource::class);
|
||||
$source->expects('read')->with(5, $role)->andReturn('Leitung');
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('taxonomies')->andReturn([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]]);
|
||||
$schema->allows('fields')->andReturn(['role' => $role]);
|
||||
$schema->allows('sourceOf')->andReturn($source);
|
||||
$post = new WP_Post(['ID' => 5, 'post_type' => 'lit_person', 'post_content' => '<p>Hallo</p>', 'post_author' => '2']);
|
||||
|
||||
$full = (new PostPresenter($schema))->full($post);
|
||||
|
||||
self::assertSame('<p>Hallo</p>', $full['content']);
|
||||
self::assertSame(['category' => [['id' => 4, 'name' => 'News', 'slug' => 'news']]], $full['terms']);
|
||||
self::assertSame(['role' => 'Leitung'], $full['fields']);
|
||||
self::assertSame(['id' => 33, 'url' => 'https://kunde.de/wp-content/uploads/foto.jpg', 'alt' => 'Porträt'], $full['featured_image']);
|
||||
self::assertSame(['id' => 2, 'name' => 'Jan'], $full['author']);
|
||||
self::assertSame(Rev::of('<p>Hallo</p>'), $full['rev']);
|
||||
self::assertSame([], $full['hints']);
|
||||
}
|
||||
|
||||
public function testDiviContentIsWithheldAndPointsToTheDiviOutline(): void
|
||||
{
|
||||
$this->stubLinks();
|
||||
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => $key === '_et_pb_use_builder' ? 'on' : '');
|
||||
Functions\when('get_post_thumbnail_id')->justReturn(0);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('taxonomies')->andReturn([]);
|
||||
$schema->allows('fields')->andReturn([]);
|
||||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_content' => '<!-- wp:divi/section /-->']);
|
||||
|
||||
$full = (new PostPresenter($schema))->full($post);
|
||||
|
||||
self::assertTrue($full['divi']);
|
||||
self::assertNull($full['content']);
|
||||
self::assertNull($full['featured_image']);
|
||||
self::assertStringContainsString('lit-divi-outline', $full['hints'][0]);
|
||||
self::assertInstanceOf(stdClass::class, $full['terms']);
|
||||
self::assertInstanceOf(stdClass::class, $full['fields']);
|
||||
}
|
||||
|
||||
public function testDiviIsAlsoDetectedFromBlockMarkup(): void
|
||||
{
|
||||
Functions\when('get_post_meta')->justReturn('');
|
||||
|
||||
self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x <!-- wp:divi/text {} /--> y'])));
|
||||
self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '<!-- wp:paragraph -->'])));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ final class WordPressIntegrationTest extends TestCase
|
||||
/** Grows with every task that adds abilities – keep in the order of abilities(). */
|
||||
private const EXPECTED = [
|
||||
'lit/content-types',
|
||||
'lit/content-query',
|
||||
'lit/content-get',
|
||||
];
|
||||
|
||||
public function testProvidesTheWordPressAbilitiesInOrder(): void
|
||||
|
||||
Reference in New Issue
Block a user