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:
2026-09-25 22:39:43 +02:00
co-authored by Claude Opus 5.5
parent 95aae74466
commit d68e9b1f04
10 changed files with 806 additions and 4 deletions
@@ -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