PodsFieldSource maps hidden, read_only, admin_only and restrict_role/-capability like Pods' own view: hidden -> new status HIDDEN, read_only -> READONLY, restricted and not permitted -> READONLY with read_only_restricted, else HIDDEN. HIDDEN fields are not described, read out, queried or written (they look unknown) but stay in the schema for the media usage search; write() re-checks before Pods' save(). Required-but-restricted fields no longer block a create. Pods is not active on DDEV: covered by unit tests with a field-option fixture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
219 lines
9.4 KiB
PHP
219 lines
9.4 KiB
PHP
<?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'], []);
|
|
}
|
|
|
|
/**
|
|
* Security audit M4: Pods field restrictions, as Pods' own view applies them (Pods::view):
|
|
* hidden → hidden; read_only → readonly; restricted (admin_only, restrict_role/-capability)
|
|
* and not permitted → readonly with read_only_restricted, else hidden.
|
|
*
|
|
* @param array<string, bool|null> $permitted Field name => pods_permission() (null = unrestricted).
|
|
* @return array<string, string> Field name => status.
|
|
*/
|
|
private function statuses(array $permitted): array
|
|
{
|
|
$pod = Mockery::mock();
|
|
$pod->allows('get_type')->andReturn('post_type');
|
|
$pod->allows('get_fields')->andReturn([
|
|
$this->field('open', 'text'),
|
|
$this->field('internal', 'text', ['hidden' => '1']),
|
|
$this->field('fixed', 'text', ['read_only' => 1]),
|
|
$this->field('boss', 'text', ['admin_only' => '1']),
|
|
$this->field('team', 'text', ['restrict_role' => '1', 'read_only_restricted' => '1']),
|
|
$this->field('cap', 'number', ['restrict_capability' => '1']),
|
|
]);
|
|
$source = new PodsFieldSource(
|
|
static fn (string $name) => $pod,
|
|
null,
|
|
static fn (object $field): ?bool => $permitted[$field->get_name()] ?? null
|
|
);
|
|
|
|
$statuses = [];
|
|
foreach ($source->fieldsFor('lit_person') as $definition) {
|
|
$statuses[$definition->key] = $definition->status;
|
|
}
|
|
|
|
return $statuses;
|
|
}
|
|
|
|
public function testFieldRestrictionsBecomeReadonlyOrHidden(): void
|
|
{
|
|
self::assertSame(
|
|
[
|
|
'open' => FieldDefinition::WRITABLE,
|
|
'internal' => FieldDefinition::HIDDEN,
|
|
'fixed' => FieldDefinition::READONLY,
|
|
'boss' => FieldDefinition::HIDDEN,
|
|
'team' => FieldDefinition::READONLY,
|
|
'cap' => FieldDefinition::HIDDEN,
|
|
],
|
|
$this->statuses(['boss' => false, 'team' => false, 'cap' => false])
|
|
);
|
|
self::assertSame(
|
|
['open', 'internal', 'fixed', 'boss', 'team', 'cap'],
|
|
array_keys($this->statuses([])),
|
|
'hidden fields stay in the schema (media usage search), only their status changes'
|
|
);
|
|
$allowed = $this->statuses(['boss' => true, 'team' => true, 'cap' => true]);
|
|
self::assertSame(FieldDefinition::WRITABLE, $allowed['boss']);
|
|
self::assertSame(FieldDefinition::WRITABLE, $allowed['team']);
|
|
self::assertSame(FieldDefinition::HIDDEN, $allowed['internal'], 'hidden is hidden for everyone, as in Pods');
|
|
}
|
|
|
|
/** Re-check in write(): a restricted field is never handed to Pods' save(), which skips its own checks. */
|
|
public function testWriteRefusesRestrictedFields(): void
|
|
{
|
|
$pod = Mockery::mock();
|
|
$pod->expects('save')->never();
|
|
$definitions = [
|
|
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
|
|
'boss' => new FieldDefinition('boss', 'Boss', 'string', 'pods', FieldDefinition::HIDDEN),
|
|
];
|
|
|
|
$this->expectException(RuntimeException::class);
|
|
|
|
(new PodsFieldSource(null, static fn () => $pod))
|
|
->write(5, 'lit_person', ['role' => 'x', 'boss' => 'y'], $definitions);
|
|
}
|
|
}
|