tests/Support/AssertsToolErrors (used by TestCase): assertToolError($result, code)
checks the WP_Error code lit_<code> and the code of the client JSON and returns the
payload; clientError() decodes the payload; errorFields() returns the fields and
asserts they exist. Replaces the private clientError() decoders of
DiviWriteAbilitiesTest/DiviReplaceTextAbilityTest, 216 single-line
assertSame('lit_…', …->get_error_code()) assertions, 24 get_error_data()['fields']
digs and two json_decode(message) reads. ErrorsTest keeps json_decode: it tests
the format itself. Tests 1404 -> 1409 (AssertsToolErrorsTest); no test has fewer
assertions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
340 lines
16 KiB
PHP
340 lines
16 KiB
PHP
<?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 = self::errorFields($result);
|
||
self::assertToolError($result, 'validation_failed');
|
||
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) self::errorFields($result)[$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.'], self::errorFields($result));
|
||
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());
|
||
}
|
||
}
|
||
}
|