Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 106a099483 fix: check WordPress meta capabilities per field key
Fields from the meta/sample sources need edit_post_meta (update, checked in
PostWriter::prepare) or add_post_meta (create, checked right after the insert;
a refusal deletes the new draft again) for their key; a refusal is a
fields.<key> validation error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 01:24:20 +02:00

340 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class PostWriterTest extends TestCase
{
private function stubTheme(): void
{
$theme = Mockery::mock();
$theme->allows('get_page_templates')->with(null, 'lit_person')->andReturn(['tpl-wide.php' => 'Breit']);
Functions\when('wp_get_theme')->justReturn($theme);
}
public function testPrepareCollectsAllErrorsWithPaths(): void
{
$this->stubTheme();
Functions\when('get_object_taxonomies')->justReturn([]);
Functions\when('post_type_supports')->justReturn(false);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 3, 'post_type' => 'post']));
Functions\when('is_post_type_hierarchical')->justReturn(true);
Functions\when('get_post_ancestors')->justReturn([]);
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
$result = $writer->prepare('lit_person', [
'terms' => ['genre' => [1]],
'featured_image' => 9,
'parent' => 3,
'template' => 'fehlt.php',
], true);
$fields = $result->get_error_data()['fields'];
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']);
self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']);
self::assertStringContainsString('denselben Inhaltstyp', $fields['parent']);
self::assertStringContainsString('tpl-wide.php', $fields['template']);
}
private function writerWithoutFields(): PostWriter
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
return new PostWriter($schema, new FieldValidator(), new TermAssigner());
}
/** @param array<string, mixed> $input */
private function errorFor(PostWriter $writer, array $input, string $path): string
{
$result = $writer->prepare('page', $input, false);
self::assertFalse(is_array($result), $path . ' should be rejected');
return (string) $result->get_error_data()['fields'][$path];
}
public function testParentMustBeAReadableNonDescendantEntryOfAHierarchicalType(): void
{
$writer = $this->writerWithoutFields();
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 8, 'post_type' => 'page']));
Functions\when('get_post_ancestors')->justReturn([5, 1]);
Functions\when('current_user_can')->justReturn(true);
Functions\when('is_post_type_hierarchical')->justReturn(false);
self::assertStringContainsString('hierarchisch', $this->errorFor($writer, ['parent' => 8], 'parent'));
Functions\when('is_post_type_hierarchical')->justReturn(true);
self::assertStringContainsString('selbst', $this->errorFor($writer, ['post_id' => 8, 'parent' => 8], 'parent'));
self::assertStringContainsString('untergeordnet', $this->errorFor($writer, ['post_id' => 5, 'parent' => 8], 'parent'));
self::assertIsArray($writer->prepare('page', ['post_id' => 9, 'parent' => 8], false));
Functions\when('current_user_can')->justReturn(false);
self::assertStringContainsString('Berechtigung', $this->errorFor($writer, ['parent' => 8], 'parent'));
}
public function testFeaturedImageMustBeReadable(): void
{
$writer = $this->writerWithoutFields();
Functions\when('post_type_supports')->justReturn(true);
Functions\when('wp_attachment_is_image')->justReturn(true);
Functions\expect('current_user_can')->once()->with('read_post', 9)->andReturn(false);
self::assertStringContainsString('Berechtigung', $this->errorFor($writer, ['featured_image' => 9], 'featured_image'));
}
public function testKnownDefaultAndEmptyTemplatesAreValid(): void
{
$this->stubTheme();
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
foreach (['tpl-wide.php', 'default', ''] as $template) {
self::assertIsArray($writer->prepare('lit_person', ['template' => $template], false), $template);
}
}
public function testPrepareReturnsValuesTermsAndWarnings(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown')]);
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))
->prepare('page', ['fields' => ['city' => 'Trier']], false);
self::assertSame(['city' => 'Trier'], $result['fields']);
self::assertSame([], $result['terms']);
self::assertCount(1, $result['warnings']);
}
public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
$checked = [];
Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool {
$checked[] = array_merge([$cap], $args);
return $args !== [5, 'secret'];
});
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare(
'page',
['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']],
false
);
self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']);
self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked);
}
public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true));
self::assertSame(
['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'],
$writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta')
);
}
public function testPostDataMapsOnlyGivenKeys(): void
{
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());
// wp_insert_post() ignores an empty page_template, so "empty = default template" is sent as 'default'.
self::assertSame(
['post_title' => 'Team', 'post_name' => 'team', 'page_template' => 'default'],
$writer->postData(['title' => 'Team', 'slug' => 'team', 'template' => '', 'fields' => []])
);
self::assertTrue($writer->hasExtras(['featured_image' => null]));
self::assertFalse($writer->hasExtras(['title' => 'x', 'fields' => []]));
}
public function testWriteExtrasGroupsFieldsBySourceAndHandlesTheFeaturedImage(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
$definitions = ['city' => $city, 'role' => $role];
$pods = Mockery::mock(FieldSource::class);
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Leitung'], $definitions);
$sample = Mockery::mock(FieldSource::class);
$sample->expects('write')->with(5, 'lit_person', ['city' => 'Trier'], $definitions);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn($definitions);
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
Functions\expect('delete_post_thumbnail')->once()->with(5);
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
5,
'lit_person',
['fields' => ['role' => 'Leitung', 'city' => 'Trier'], 'terms' => [], 'warnings' => []],
['featured_image' => 0]
);
}
public function testAFeaturedImageThatDidNotStickThrows(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
Functions\when('set_post_thumbnail')->justReturn(false);
Functions\when('get_post_thumbnail_id')->justReturn(0);
$this->expectException(RuntimeException::class);
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
5,
'lit_person',
['fields' => [], 'terms' => [], 'warnings' => []],
['featured_image' => 9]
);
}
public function testSnapshotKeepsTheOldValuesOfEverythingThatWillBeWrittenAndRestoreWritesThemBack(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
$definitions = ['city' => $city, 'role' => $role];
$pods = Mockery::mock(FieldSource::class);
$pods->allows('read')->with(5, $role)->andReturn('Alt');
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Alt'], $definitions);
$sample = Mockery::mock(FieldSource::class);
$sample->allows('read')->with(5, $city)->andReturn('');
$sample->expects('write')->with(5, 'lit_person', ['city' => ''], $definitions);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn($definitions);
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
Functions\expect('wp_get_object_terms')->once()->with(5, 'category', ['fields' => 'ids'])->andReturn(['2', 3]);
Functions\when('get_post_thumbnail_id')->justReturn(7);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
$snapshot = $writer->snapshot(
5,
'lit_person',
['fields' => ['role' => 'Neu', 'city' => 'Trier'], 'terms' => ['category' => [4]], 'warnings' => []],
['featured_image' => 9]
);
self::assertSame(
['fields' => ['role' => 'Alt', 'city' => ''], 'terms' => ['category' => [2, 3]], 'featured_image' => 7],
$snapshot
);
Functions\expect('wp_set_object_terms')->once()->with(5, [2, 3], 'category', false)->andReturn([2, 3]);
Functions\expect('set_post_thumbnail')->once()->with(5, 7)->andReturn(true);
$writer->restore(5, 'lit_person', $snapshot);
}
public function testSnapshotTellsAbsentMetaFromStoredEmptyValues(): void
{
$kept = new FieldDefinition('kept', 'kept', 'string', 'meta', 'writable');
$absent = new FieldDefinition('absent', 'absent', 'string', 'meta', 'writable');
$flag = new FieldDefinition('flag', 'flag', 'boolean', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->allows('read')->andReturn(false);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['absent' => $absent, 'flag' => $flag, 'kept' => $kept]);
$schema->allows('sourceOf')->andReturnUsing(
static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : new RegisteredMetaSource()
);
Functions\when('get_post_meta')->justReturn('');
Functions\when('metadata_exists')->alias(static fn (string $type, int $id, string $key): bool => $key === 'kept');
$snapshot = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->snapshot(
5,
'lit_person',
['fields' => ['kept' => 'x', 'absent' => 'y', 'flag' => true], 'terms' => [], 'warnings' => []],
[]
);
self::assertSame(['kept' => '', 'absent' => null, 'flag' => false], $snapshot['fields']);
}
public function testTermsCreatedByWriteExtrasCanBeDeletedAgain(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->allows('write')->andThrow(new RuntimeException('pods kaputt'));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['role' => $role]);
$schema->allows('sourceOf')->andReturn($pods);
Functions\when('wp_insert_term')->justReturn(['term_id' => 15]);
Functions\when('wp_set_object_terms')->justReturn([15]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
try {
$writer->writeExtras(
5,
'lit_person',
['fields' => ['role' => 'x'], 'terms' => ['category' => ['Neu']], 'warnings' => []],
[]
);
self::fail('RuntimeException expected');
} catch (RuntimeException $e) {
self::assertSame('pods kaputt', $e->getMessage());
}
Functions\expect('wp_delete_term')->once()->with(15, 'category')->andReturn(true);
$writer->deleteCreatedTerms();
$writer->deleteCreatedTerms(); // nothing left – no second call
}
public function testRestoreTriesEveryStepAndReportsFailures(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->expects('write')->andThrow(new RuntimeException('pods kaputt'));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['role' => $role]);
$schema->allows('sourceOf')->andReturn($pods);
Functions\expect('wp_set_object_terms')->once()->andReturn([2]);
Functions\expect('delete_post_thumbnail')->once()->andReturn(true);
try {
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->restore(
5,
'lit_person',
['fields' => ['role' => 'Alt'], 'terms' => ['category' => [2]], 'featured_image' => 0]
);
self::fail('RuntimeException expected');
} catch (RuntimeException $e) {
self::assertStringContainsString('pods kaputt', $e->getMessage());
}
}
}