feat(pods): Verhaltensprobe als Prüfschritt

Beim Prüfen schreibt und liest PodsProbe auf einem Wegwerf-Entwurf ein Textfeld genau so, wie LIT MCP es tut, vergleicht die Werte und löscht den Entwurf wieder. Bevorzugt von Pods angelegte Typen; ohne Pod mit Textfeld gilt der Schritt als grün mit Hinweis „nicht geprüft“. Ein roter Schritt sperrt Pods über die Berichtssperre. Liegengebliebene Probe-Einträge räumt der Load-Hook des Reiters auf. Pods-Smoke um Probe und Bereinigung erweitert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 14:33:26 +01:00
co-authored by Claude Opus 5.5
parent 42aa4f8dc9
commit 1a294b7881
11 changed files with 812 additions and 10 deletions
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\Pods\PodsProbe;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
use WP_Post;
final class PodsProbeTest extends TestCase
{
/** @var list<int> */
private array $deleted = [];
/** @var array<string, mixed> What the fake Pods object holds. */
private array $stored = [];
/** @var array<string, mixed>|null The wp_insert_post arguments. */
private ?array $inserted = null;
protected function setUp(): void
{
parent::setUp();
$this->deleted = [];
$this->stored = [];
$this->inserted = null;
Functions\when('wp_insert_post')->alias(function (array $post): int {
$this->inserted = $post;
return 77;
});
Functions\when('get_post_type')->justReturn('lit_person');
Functions\when('is_wp_error')->alias(static fn ($value): bool => $value instanceof WP_Error);
Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post {
self::assertTrue($force, 'never to the trash');
$this->deleted[] = $id;
return new WP_Post(['ID' => $id]);
});
}
/** @param array<string, mixed> $args */
private function field(
string $name,
string $type,
bool $required = false,
array $args = [],
bool $multiple = false
): object {
$field = Mockery::mock();
$field->allows('get_name')->andReturn($name);
$field->allows('get_label')->andReturn(ucfirst($name));
$field->allows('get_type')->andReturn($type);
$field->allows('is_required')->andReturn($required);
$field->allows('is_multi_value')->andReturn($multiple);
$field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default);
return $field;
}
/** @param list<object> $fields */
private function schema(array $fields, string $object = ''): object
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_arg')->andReturnUsing(
static fn (string $key, $default = null) => $key === 'object' ? $object : $default
);
$pod->allows('get_fields')->andReturn($fields);
return $pod;
}
/** @param (Closure(array<string, mixed>): mixed)|null $save */
private function entry(?Closure $save = null, ?string $readBack = null): object
{
$entry = Mockery::mock();
$entry->allows('save')->andReturnUsing(function (array $data) use ($save) {
if ($save !== null) {
return $save($data);
}
$this->stored = $data;
return 77;
});
$entry->allows('field')->andReturnUsing(
fn (array $args) => $readBack ?? $this->stored[$args['name']] ?? ''
);
return $entry;
}
/** @param list<object>|null $fields */
private function probe(?array $fields = null, ?object $entry = null): PodsProbe
{
$pod = $this->schema($fields ?? [$this->field('role', 'text')]);
$entry ??= $this->entry();
$source = new PodsFieldSource(
static fn (): bool => true,
static fn (string $name) => $name === 'lit_person' ? $pod : false,
static fn () => $entry,
static fn (object $field): ?bool => null
);
return new PodsProbe(
$source,
static fn (string $name) => $name === 'lit_person' ? $pod : false,
static fn (): array => ['post', 'lit_person']
);
}
/** Captures what Log::failure writes to the PHP error log while $run runs. */
private function logged(Closure $run): string
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$run();
return (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testGreenWhenTheWrittenValueIsReadBackAndTheEntryIsGone(): void
{
self::assertSame([], $this->probe()->run());
self::assertSame(['role' => PodsProbe::VALUE], $this->stored);
self::assertSame([77], $this->deleted);
}
public function testCreatesAMarkedDraftWithThePrefixedTitleInThePodsPostType(): void
{
$this->probe()->run();
self::assertSame('lit_person', $this->inserted['post_type']);
self::assertSame('draft', $this->inserted['post_status']);
self::assertStringStartsWith(PodsProbe::TITLE, $this->inserted['post_title']);
self::assertSame(['_lit_mcp_check_entry' => '1'], $this->inserted['meta_input']);
}
public function testADifferingValueIsAFixedTextAndTheValueGoesToTheLogOnly(): void
{
$problems = [];
$logged = $this->logged(function () use (&$problems): void {
$problems = $this->probe(null, $this->entry(null, 'etwas anderes'))->run();
});
self::assertSame(['Gelesener Wert weicht vom geschriebenen ab.'], $problems);
self::assertStringContainsString('role', $logged);
self::assertStringContainsString('etwas anderes', $logged);
self::assertSame([77], $this->deleted);
}
public function testAnExceptionBecomesTheFixedTextAndALogLineAndTheEntryIsDeleted(): void
{
$entry = $this->entry(static function (): never {
throw new RuntimeException('Pods kaputt');
});
$problems = [];
$logged = $this->logged(function () use (&$problems, $entry): void {
$problems = $this->probe(null, $entry)->run();
});
self::assertSame(['Pods-Probe abgebrochen (Details im PHP-Error-Log).'], $problems);
self::assertStringContainsString('Pods kaputt', $logged);
self::assertSame([77], $this->deleted);
}
public function testAFailingSchemaLookupIsTheFixedTextToo(): void
{
$broken = static fn () => throw new RuntimeException('Schema kaputt');
$probe = new PodsProbe(
new PodsFieldSource(static fn (): bool => true, $broken),
$broken,
static fn (): array => ['lit_person']
);
$problems = [];
$logged = $this->logged(function () use (&$problems, $probe): void {
$problems = $probe->run();
});
self::assertSame(['Pods-Probe abgebrochen (Details im PHP-Error-Log).'], $problems);
self::assertStringContainsString('Schema kaputt', $logged);
self::assertNull($this->inserted);
}
public function testNothingToProbeWithoutAPodWithAPlainWritableTextField(): void
{
$fields = [$this->field('mail', 'email'), $this->field('intro', 'wysiwyg')];
self::assertNull($this->probe($fields)->run());
self::assertNull($this->probe([])->run());
self::assertNull($this->inserted, 'nothing is created without a target');
self::assertSame([], $this->deleted);
}
public function testARequiredTextFieldIsFineBecauseTheProbeWritesAValue(): void
{
self::assertSame([], $this->probe([$this->field('role', 'text', true)])->run());
self::assertSame(['role' => PodsProbe::VALUE], $this->stored);
}
public function testPicksThePlainTextFieldAmongOthers(): void
{
$this->probe([$this->field('mail', 'email'), $this->field('title2', 'text')])->run();
self::assertSame(['title2' => PodsProbe::VALUE], $this->stored);
}
public function testAnEntryThatCannotBeCreatedIsAFixedTextAndNothingIsDeleted(): void
{
Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'Datenbank voll'));
$problems = [];
$logged = $this->logged(function () use (&$problems): void {
$problems = $this->probe()->run();
});
self::assertSame(['Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).'], $problems);
self::assertStringContainsString('Datenbank voll', $logged);
self::assertSame([], $this->deleted);
}
public function testAnEntryThatCannotBeDeletedIsReported(): void
{
Functions\when('wp_delete_post')->justReturn(false);
$problems = $this->probe()->run();
self::assertCount(1, $problems);
self::assertStringContainsString('77', $problems[0]);
self::assertStringContainsString('gelöscht', $problems[0]);
}
public function testOrphansAreLookedForInEveryPostTypeWithThePodsMarker(): void
{
Functions\when('get_post_types')->justReturn(['post' => 'post', 'lit_person' => 'lit_person']);
$query = null;
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
$query = $args;
return [
new WP_Post(['ID' => 5, 'post_title' => PodsProbe::TITLE . ' 2026-11-06 10:00']),
new WP_Post(['ID' => 6, 'post_title' => 'Echte Person']),
];
});
self::assertSame([5], PodsProbe::removeOrphans());
self::assertSame([5], $this->deleted);
self::assertSame('_lit_mcp_check_entry', $query['meta_key']);
self::assertSame(['post', 'lit_person'], $query['post_type']);
self::assertSame('post_modified_gmt', $query['date_query'][0]['column']);
}
/** @param array<string, object> $pods */
private function probeOver(array $pods, ?callable $postTypes = null): PodsProbe
{
$load = static fn (string $name) => $pods[$name] ?? false;
$source = new PodsFieldSource(
static fn (): bool => true,
$load,
fn () => $this->entry(),
static fn (object $field): ?bool => null
);
return new PodsProbe($source, $load, $postTypes);
}
public function testPodsOwnPostTypesComeBeforeExtendedForeignOnesAndPostAndPage(): void
{
$fields = [$this->field('role', 'text')];
$pods = [
'post' => $this->schema($fields, 'post'),
'event' => $this->schema($fields, 'event'),
'lit_person' => $this->schema($fields),
];
$this->probeOver($pods, static fn (): array => ['post', 'event', 'lit_person'])->run();
self::assertSame('lit_person', $this->inserted['post_type']);
}
public function testAnExtendedForeignTypeComesBeforePostAndPage(): void
{
$fields = [$this->field('role', 'text')];
$pods = ['post' => $this->schema($fields, 'post'), 'event' => $this->schema($fields, 'event')];
$this->probeOver($pods, static fn (): array => ['post', 'event'])->run();
self::assertSame('event', $this->inserted['post_type']);
}
public function testPostIsTheLastResort(): void
{
$pods = ['post' => $this->schema([$this->field('role', 'text')], 'post')];
$this->probeOver($pods, static fn (): array => ['page', 'post'])->run();
self::assertSame('post', $this->inserted['post_type']);
}
public function testALowerRankedTypeIsUsedWhenTheBetterOnesHaveNoTextField(): void
{
$pods = [
'lit_person' => $this->schema([$this->field('mail', 'email')]),
'post' => $this->schema([$this->field('role', 'text')], 'post'),
];
$this->probeOver($pods, static fn (): array => ['lit_person', 'post'])->run();
self::assertSame('post', $this->inserted['post_type']);
}
public function testReadOnlyAndMultipleTextFieldsAreNotProbed(): void
{
$fields = [
$this->field('locked', 'text', false, ['read_only' => true]),
$this->field('many', 'text', false, [], true),
];
self::assertNull($this->probe($fields)->run());
self::assertNull($this->inserted);
$this->probe([...$fields, $this->field('role', 'text')])->run();
self::assertSame(['role' => PodsProbe::VALUE], $this->stored);
}
public function testTheDefaultPostTypesAreCustomTypesThenPostAndPage(): void
{
Functions\when('get_post_types')->justReturn(['lit_person' => 'lit_person', 'post' => 'post']);
$asked = [];
$probe = new PodsProbe(
new PodsFieldSource(static fn (): bool => true, static fn () => false),
static function (string $name) use (&$asked) {
$asked[] = $name;
return false;
}
);
self::assertNull($probe->run());
self::assertSame(['lit_person', 'post', 'page'], $asked);
}
}