feat: field validation, term resolution, post guard and revision points

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:29:15 +02:00
co-authored by Claude Opus 5.5
parent 98a910f056
commit 95aae74466
11 changed files with 748 additions and 0 deletions
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Tests\Unit\TestCase;
final class FieldValidatorTest extends TestCase
{
/** @return array<string, FieldDefinition> */
private function definitions(): array
{
return [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
]),
'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [
'object_type' => 'post_type',
'name' => 'lit_person',
]),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [
'object_type' => 'media',
'name' => null,
]),
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
];
}
public function testValidValuesPassUnchanged(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false);
$values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01'];
$result = (new FieldValidator())->validate($this->definitions(), $values, true);
self::assertSame([], $result['errors']);
self::assertSame($values, $result['values']);
}
public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void
{
$validator = new FieldValidator();
self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']);
self::assertSame(
'Pflichtfeld darf nicht geleert werden.',
$validator->validate($this->definitions(), ['role' => ''], false)['errors']['role']
);
self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']);
}
public function testUnknownReadonlyAndInvalidValuesAreReported(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), [
'role' => 'x',
'nope' => 1,
'secret' => 'x',
'level' => 'expert',
'mentor' => 7,
'photos' => 3,
'start' => '01.10.2026',
], false)['errors'];
self::assertStringContainsString('lit-content-types', $errors['nope']);
self::assertSame('Feld ist schreibgeschützt.', $errors['secret']);
self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']);
self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']);
self::assertSame('Liste erwartet.', $errors['photos']);
self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']);
self::assertArrayNotHasKey('role', $errors);
}
public function testMissingRelatedPostAndNonMediaAreRejected(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors'];
self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']);
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
}
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
{
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
self::assertSame([], $result['errors']);
self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']);
self::assertCount(1, $result['warnings']);
self::assertStringContainsString('city', $result['warnings'][0]);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Term;
final class TermAssignerTest extends TestCase
{
private function stubTaxonomies(bool $mayEditTerms = true, bool $mayAssign = true): void
{
Functions\when('get_object_taxonomies')->justReturn(['category', 'post_tag']);
Functions\when('get_taxonomy')->justReturn((object) [
'cap' => (object) ['assign_terms' => 'assign_categories', 'edit_terms' => 'manage_categories'],
]);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => match ($cap) {
'assign_categories' => $mayAssign,
'manage_categories' => $mayEditTerms,
default => false,
});
Functions\when('get_term')->alias(static fn (int $id) => $id === 4 ? new WP_Term(['term_id' => 4]) : null);
Functions\when('term_exists')->alias(
static fn (string $name) => $name === 'News' ? ['term_id' => '9', 'term_taxonomy_id' => '9'] : null
);
}
public function testResolvesIdsExistingNamesAndNewNames(): void
{
$this->stubTaxonomies();
$result = (new TermAssigner())->resolve('post', ['category' => [4, 'News', 'Neu']]);
self::assertSame([], $result['errors']);
self::assertSame(['category' => [4, 9, 'Neu']], $result['terms']);
}
public function testReportsForeignTaxonomyMissingIdsAndMissingRights(): void
{
$this->stubTaxonomies(false);
$result = (new TermAssigner())->resolve('post', [
'genre' => [1],
'category' => [77],
'post_tag' => ['Unbekannt'],
]);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $result['errors']['genre']);
self::assertSame('Begriff 77 existiert nicht.', $result['errors']['category']);
self::assertStringContainsString('keine Berechtigung', $result['errors']['post_tag']);
}
public function testAssignRightsAreRequired(): void
{
$this->stubTaxonomies(true, false);
$result = (new TermAssigner())->resolve('post', ['category' => [4]]);
self::assertSame('Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.', $result['errors']['category']);
}
public function testAssignCreatesNewTermsAndReplacesTheAssignment(): void
{
Functions\expect('wp_insert_term')->once()->with('Neu', 'category')->andReturn(['term_id' => 15]);
Functions\expect('wp_set_object_terms')->once()->with(5, [4, 15], 'category', false)->andReturn([4, 15]);
(new TermAssigner())->assign(5, ['category' => [4, 'Neu']]);
}
public function testAssignFailureThrows(): void
{
Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
$this->expectException(RuntimeException::class);
(new TermAssigner())->assign(5, ['category' => [4]]);
}
}