feat: Pods field source for schema detection and writes

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:13:41 +02:00
co-authored by Claude Opus 5.5
parent b3f4b7ba65
commit 4a652240a3
3 changed files with 378 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Creates or removes the Pods test content type "lit_person" for integration tests.
*
* Usage (inside DDEV, Pods must be active):
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
*
* No strict_types: wp eval-file runs this through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
if (! function_exists('pods_api')) {
fwrite(STDERR, "Pods is not active\n");
exit(1);
}
$action = $args[0] ?? 'create';
$api = pods_api();
if ($action === 'delete') {
if ($api->load_pod(['name' => 'lit_person'])) {
$api->delete_pod(['name' => 'lit_person'], false, true);
}
echo "deleted\n";
return;
}
if (! $api->load_pod(['name' => 'lit_person'])) {
$api->save_pod([
'name' => 'lit_person',
'label' => 'Personen',
'label_singular' => 'Person',
'type' => 'post_type',
'storage' => 'meta',
'public' => 1,
'show_ui' => 1,
]);
}
$fields = [
['name' => 'role', 'label' => 'Funktion', 'type' => 'text', 'required' => 1],
[
'name' => 'level',
'label' => 'Stufe',
'type' => 'pick',
'pick_object' => 'custom-simple',
'pick_custom' => "junior|Junior\nsenior|Senior",
],
[
'name' => 'mentor',
'label' => 'Mentor',
'type' => 'pick',
'pick_object' => 'post_type',
'pick_val' => 'lit_person',
'pick_format_type' => 'single',
],
];
foreach ($fields as $field) {
$api->save_field(array_merge(['pod' => 'lit_person'], $field));
}
echo "created\n";
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use Brain\Monkey\Functions;
use Exception;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class PodsFieldSourceTest extends TestCase
{
/** @param array<string, mixed> $args */
private function field(string $name, string $type, array $args = [], array $extra = []): 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($extra['required'] ?? false);
$field->allows('is_multi_value')->andReturn($extra['multi'] ?? false);
$field->allows('get_related_object_type')->andReturn($extra['related_type'] ?? null);
$field->allows('get_related_object_name')->andReturn($extra['related_name'] ?? null);
$field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default);
return $field;
}
private function source(?object $pod): PodsFieldSource
{
return new PodsFieldSource(static fn (string $name) => $pod ?? false);
}
/** @return array<string, array<string, mixed>> */
private function describe(): array
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([
'role' => $this->field('role', 'text', ['description' => 'Funktion im Team'], ['required' => true]),
'level' => $this->field('level', 'pick', [
'pick_object' => 'custom-simple',
'pick_custom' => "junior|Junior\nsenior|Senior\nplain",
]),
'mentor' => $this->field('mentor', 'pick', ['pick_object' => 'post_type'], [
'related_type' => 'post_type',
'related_name' => 'lit_person',
]),
'photos' => $this->field('photos', 'file', [], ['multi' => true]),
'intro' => $this->field('intro', 'heading'),
'mystery' => $this->field('mystery', 'fancy-addon-type'),
]);
$fields = $this->source($pod)->fieldsFor('lit_person');
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
}
public function testMapsPodsFieldTypes(): void
{
$fields = $this->describe();
self::assertSame(['role', 'level', 'mentor', 'photos', 'mystery'], array_keys($fields));
self::assertSame('string', $fields['role']['type']);
self::assertTrue($fields['role']['required']);
self::assertSame('Funktion im Team', $fields['role']['description']);
self::assertSame('pods', $fields['role']['source']);
self::assertSame(FieldDefinition::WRITABLE, $fields['role']['status']);
self::assertSame('relation', $fields['mentor']['type']);
self::assertSame(['object_type' => 'post_type', 'name' => 'lit_person'], $fields['mentor']['related']);
self::assertSame('file', $fields['photos']['type']);
self::assertTrue($fields['photos']['multiple']);
self::assertSame(['object_type' => 'media', 'name' => null], $fields['photos']['related']);
self::assertSame('unknown', $fields['mystery']['type']);
self::assertSame(FieldDefinition::UNKNOWN, $fields['mystery']['status']);
}
public function testParsesCustomSimpleChoices(): void
{
self::assertSame(
[
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
['value' => 'plain', 'label' => 'plain'],
],
$this->describe()['level']['choices']
);
}
public function testIgnoresNonPodsAndNonPostTypePods(): void
{
$taxonomyPod = Mockery::mock();
$taxonomyPod->allows('get_type')->andReturn('taxonomy');
self::assertSame([], $this->source(null)->fieldsFor('page'));
self::assertSame([], $this->source($taxonomyPod)->fieldsFor('genre'));
}
public function testReadsRelationsAsIdsAndOtherFieldsRaw(): void
{
Functions\when('get_post_type')->justReturn('lit_person');
$pod = Mockery::mock();
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
self::assertSame(
[3, 4],
$source->read(5, new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true))
);
self::assertSame('Leitung', $source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
}
public function testWriteSavesThroughPodsAndMapsNullToEmpty(): void
{
$definitions = [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true),
];
$pod = Mockery::mock();
$pod->expects('save')
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
->andReturn(5);
(new PodsFieldSource(null, static fn () => $pod))
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
}
public function testWriteFailureBecomesRuntimeException(): void
{
$pod = Mockery::mock();
$pod->allows('save')->andThrow(new Exception('Pods error'));
$this->expectException(RuntimeException::class);
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
}
}