feat: content schema with registered and sampled meta fields
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:
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the content model of this installation (spec §6.1): post types with a
|
||||||
|
* UI, their taxonomies and their fields merged from all field sources.
|
||||||
|
* Not final so abilities can be unit-tested with a mock.
|
||||||
|
*/
|
||||||
|
class ContentSchema
|
||||||
|
{
|
||||||
|
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
|
||||||
|
public const EXCLUDED = [
|
||||||
|
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
|
||||||
|
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
|
||||||
|
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
|
||||||
|
'et_pb_canvas', '_et_pb_speculation',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
|
||||||
|
|
||||||
|
/** @var callable(string): bool */
|
||||||
|
private $diviEnabled;
|
||||||
|
|
||||||
|
/** @var array<string, array<string, FieldDefinition>> */
|
||||||
|
private array $fieldCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<FieldSource> $sources Highest priority first.
|
||||||
|
*/
|
||||||
|
public function __construct(private array $sources, ?callable $diviEnabled = null)
|
||||||
|
{
|
||||||
|
$this->diviEnabled = $diviEnabled ?? static function (string $postType): bool {
|
||||||
|
return function_exists('et_builder_enabled_for_post_type')
|
||||||
|
&& (bool) et_builder_enabled_for_post_type($postType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function postTypeNames(): array
|
||||||
|
{
|
||||||
|
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
|
||||||
|
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
|
||||||
|
$names = array_values(array_diff(
|
||||||
|
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
|
||||||
|
$excluded
|
||||||
|
));
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isManaged(string $postType): bool
|
||||||
|
{
|
||||||
|
return in_array($postType, $this->postTypeNames(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
public function describePostTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach ($this->postTypeNames() as $name) {
|
||||||
|
$description = $this->describe($name, false);
|
||||||
|
if ($description !== null) {
|
||||||
|
$types[] = $description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null Null when the post type is not managed. */
|
||||||
|
public function describe(string $postType, bool $withFields): ?array
|
||||||
|
{
|
||||||
|
$object = $this->isManaged($postType) ? get_post_type_object($postType) : null;
|
||||||
|
if ($object === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = [
|
||||||
|
'name' => $postType,
|
||||||
|
'label' => (string) $object->labels->name,
|
||||||
|
'singular_label' => (string) $object->labels->singular_name,
|
||||||
|
'hierarchical' => (bool) $object->hierarchical,
|
||||||
|
'supports' => array_map('strval', array_keys(get_all_post_type_supports($postType))),
|
||||||
|
'divi' => $this->diviEnabled($postType),
|
||||||
|
'count' => $this->count($postType),
|
||||||
|
'taxonomies' => $this->taxonomies($postType),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($withFields) {
|
||||||
|
$description['fields'] = array_values(array_map(
|
||||||
|
static fn (FieldDefinition $field): array => $field->toArray(),
|
||||||
|
$this->fields($postType)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, FieldDefinition> */
|
||||||
|
public function fields(string $postType): array
|
||||||
|
{
|
||||||
|
if (! isset($this->fieldCache[$postType])) {
|
||||||
|
$merged = [];
|
||||||
|
foreach ($this->sources as $source) {
|
||||||
|
foreach ($source->fieldsFor($postType) as $field) {
|
||||||
|
$merged[$field->key] ??= $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($merged);
|
||||||
|
$this->fieldCache[$postType] = $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fieldCache[$postType];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceOf(FieldDefinition $field): FieldSource
|
||||||
|
{
|
||||||
|
foreach ($this->sources as $source) {
|
||||||
|
if ($source->id() === $field->source) {
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{name: string, label: string, hierarchical: bool}> */
|
||||||
|
public function taxonomies(string $postType): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach (get_object_taxonomies($postType, 'objects') as $taxonomy) {
|
||||||
|
if (! $taxonomy->show_ui) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'name' => (string) $taxonomy->name,
|
||||||
|
'label' => (string) $taxonomy->labels->name,
|
||||||
|
'hierarchical' => (bool) $taxonomy->hierarchical,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function diviEnabled(string $postType): bool
|
||||||
|
{
|
||||||
|
return ($this->diviEnabled)($postType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function count(string $postType): int
|
||||||
|
{
|
||||||
|
$counts = (array) wp_count_posts($postType);
|
||||||
|
$total = 0;
|
||||||
|
foreach (self::COUNTED_STATUSES as $status) {
|
||||||
|
$total += (int) ($counts[$status] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Description of one field of a post type, independent of where it comes from.
|
||||||
|
*/
|
||||||
|
final class FieldDefinition
|
||||||
|
{
|
||||||
|
public const WRITABLE = 'writable';
|
||||||
|
public const READONLY = 'readonly';
|
||||||
|
public const UNKNOWN = 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{value: string, label: string}>|null $choices
|
||||||
|
* @param array{object_type: string, name: ?string}|null $related
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $key,
|
||||||
|
public readonly string $label,
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly string $source,
|
||||||
|
public readonly string $status,
|
||||||
|
public readonly bool $required = false,
|
||||||
|
public readonly bool $multiple = false,
|
||||||
|
public readonly ?array $choices = null,
|
||||||
|
public readonly ?array $related = null,
|
||||||
|
public readonly string $description = ''
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => $this->key,
|
||||||
|
'label' => $this->label,
|
||||||
|
'type' => $this->type,
|
||||||
|
'source' => $this->source,
|
||||||
|
'status' => $this->status,
|
||||||
|
'required' => $this->required,
|
||||||
|
'multiple' => $this->multiple,
|
||||||
|
'choices' => $this->choices,
|
||||||
|
'related' => $this->related,
|
||||||
|
'description' => $this->description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider of field definitions for post types that can also read and write
|
||||||
|
* the values of its own fields (Pods via the Pods API, everything else via meta).
|
||||||
|
*/
|
||||||
|
interface FieldSource
|
||||||
|
{
|
||||||
|
public function id(): string;
|
||||||
|
|
||||||
|
/** @return list<FieldDefinition> */
|
||||||
|
public function fieldsFor(string $postType): array;
|
||||||
|
|
||||||
|
public function read(int $postId, FieldDefinition $field): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $values Validated values; null clears the field.
|
||||||
|
* @param array<string, FieldDefinition> $definitions All definitions of the post type, by key.
|
||||||
|
* @throws RuntimeException When the value could not be stored.
|
||||||
|
*/
|
||||||
|
public function write(int $postId, string $postType, array $values, array $definitions): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared read/write via the post meta API.
|
||||||
|
*/
|
||||||
|
abstract class MetaFieldSource implements FieldSource
|
||||||
|
{
|
||||||
|
public function read(int $postId, FieldDefinition $field): mixed
|
||||||
|
{
|
||||||
|
return get_post_meta($postId, $field->key, ! $field->multiple);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(int $postId, string $postType, array $values, array $definitions): void
|
||||||
|
{
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
$key = (string) $key;
|
||||||
|
if ($value === null) {
|
||||||
|
delete_post_meta($postId, $key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$field = $definitions[$key] ?? null;
|
||||||
|
if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) {
|
||||||
|
delete_post_meta($postId, $key);
|
||||||
|
foreach ($value as $item) {
|
||||||
|
add_post_meta($postId, $key, wp_slash($item));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($postId, $key, wp_slash($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields registered with register_meta()/register_post_meta().
|
||||||
|
*/
|
||||||
|
final class RegisteredMetaSource extends MetaFieldSource
|
||||||
|
{
|
||||||
|
private const TYPES = ['string', 'boolean', 'integer', 'number', 'array', 'object'];
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'meta';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fieldsFor(string $postType): array
|
||||||
|
{
|
||||||
|
$keys = array_merge(get_registered_meta_keys('post', ''), get_registered_meta_keys('post', $postType));
|
||||||
|
$fields = [];
|
||||||
|
foreach ($keys as $key => $args) {
|
||||||
|
$key = (string) $key;
|
||||||
|
$args = is_array($args) ? $args : [];
|
||||||
|
$type = (string) ($args['type'] ?? 'string');
|
||||||
|
$readonly = is_protected_meta($key, 'post') && empty($args['show_in_rest']);
|
||||||
|
|
||||||
|
$fields[] = new FieldDefinition(
|
||||||
|
$key,
|
||||||
|
$key,
|
||||||
|
in_array($type, self::TYPES, true) ? $type : 'unknown',
|
||||||
|
'meta',
|
||||||
|
$readonly ? FieldDefinition::READONLY : FieldDefinition::WRITABLE,
|
||||||
|
false,
|
||||||
|
! (bool) ($args['single'] ?? false),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
(string) ($args['description'] ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta keys actually used by the most recent posts of a type. Keys starting with
|
||||||
|
* "_" are internal and only shown when another source knows them.
|
||||||
|
*/
|
||||||
|
final class SampledMetaSource extends MetaFieldSource
|
||||||
|
{
|
||||||
|
private const SAMPLE_POSTS = 50;
|
||||||
|
private const MAX_KEYS = 200;
|
||||||
|
|
||||||
|
public function __construct(private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'sample';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fieldsFor(string $postType): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only core table names interpolated.
|
||||||
|
$keys = $wpdb->get_col($wpdb->prepare(
|
||||||
|
"SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ID FROM {$wpdb->posts}
|
||||||
|
WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash')
|
||||||
|
ORDER BY ID DESC LIMIT %d
|
||||||
|
) recent ON recent.ID = pm.post_id
|
||||||
|
ORDER BY pm.meta_key LIMIT %d",
|
||||||
|
$postType,
|
||||||
|
self::SAMPLE_POSTS,
|
||||||
|
self::MAX_KEYS
|
||||||
|
));
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
foreach ((array) $keys as $key) {
|
||||||
|
$key = (string) $key;
|
||||||
|
if ($key === '' || str_starts_with($key, '_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields[] = new FieldDefinition($key, $key, 'unknown', 'sample', FieldDefinition::UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class ContentSchemaTest extends TestCase
|
||||||
|
{
|
||||||
|
private function source(string $id, array $fields): FieldSource
|
||||||
|
{
|
||||||
|
$source = Mockery::mock(FieldSource::class);
|
||||||
|
$source->allows('id')->andReturn($id);
|
||||||
|
$source->allows('fieldsFor')->andReturn($fields);
|
||||||
|
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stubPostTypes(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post_types')->justReturn([
|
||||||
|
'post' => 'post',
|
||||||
|
'page' => 'page',
|
||||||
|
'attachment' => 'attachment',
|
||||||
|
'et_pb_layout' => 'et_pb_layout',
|
||||||
|
'lit_person' => 'lit_person',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostTypeNamesExcludeInternalTypesAndAreSorted(): void
|
||||||
|
{
|
||||||
|
$this->stubPostTypes();
|
||||||
|
|
||||||
|
$schema = new ContentSchema([]);
|
||||||
|
|
||||||
|
self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames());
|
||||||
|
self::assertTrue($schema->isManaged('page'));
|
||||||
|
self::assertFalse($schema->isManaged('et_pb_layout'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFieldsAreMergedByPriorityAndSorted(): void
|
||||||
|
{
|
||||||
|
$pods = $this->source('pods', [new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true)]);
|
||||||
|
$meta = $this->source('sample', [
|
||||||
|
new FieldDefinition('role', 'role', 'unknown', 'sample', 'unknown'),
|
||||||
|
new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||||||
|
]);
|
||||||
|
$schema = new ContentSchema([$pods, $meta]);
|
||||||
|
|
||||||
|
$fields = $schema->fields('lit_person');
|
||||||
|
|
||||||
|
self::assertSame(['city', 'role'], array_keys($fields));
|
||||||
|
self::assertSame('pods', $fields['role']->source);
|
||||||
|
self::assertTrue($fields['role']->required);
|
||||||
|
self::assertSame($pods, $schema->sourceOf($fields['role']));
|
||||||
|
self::assertSame($meta, $schema->sourceOf($fields['city']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDescribeIncludesTaxonomiesCountsDiviAndOptionalFields(): void
|
||||||
|
{
|
||||||
|
$this->stubPostTypes();
|
||||||
|
Functions\when('get_post_type_object')->justReturn((object) [
|
||||||
|
'hierarchical' => true,
|
||||||
|
'labels' => (object) ['name' => 'Seiten', 'singular_name' => 'Seite'],
|
||||||
|
]);
|
||||||
|
Functions\when('get_all_post_type_supports')->justReturn(['title' => true, 'editor' => true]);
|
||||||
|
Functions\when('wp_count_posts')->justReturn((object) ['publish' => 3, 'draft' => 2, 'trash' => 9]);
|
||||||
|
Functions\when('get_object_taxonomies')->justReturn([
|
||||||
|
(object) ['name' => 'category', 'show_ui' => true, 'hierarchical' => true, 'labels' => (object) ['name' => 'Kategorien']],
|
||||||
|
(object) ['name' => 'post_format', 'show_ui' => false, 'hierarchical' => false, 'labels' => (object) ['name' => 'Formate']],
|
||||||
|
]);
|
||||||
|
$schema = new ContentSchema(
|
||||||
|
[$this->source('meta', [new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')])],
|
||||||
|
static fn (string $type): bool => $type === 'page'
|
||||||
|
);
|
||||||
|
|
||||||
|
$withoutFields = $schema->describe('page', false);
|
||||||
|
$withFields = $schema->describe('page', true);
|
||||||
|
|
||||||
|
self::assertSame('Seiten', $withoutFields['label']);
|
||||||
|
self::assertSame('Seite', $withoutFields['singular_label']);
|
||||||
|
self::assertTrue($withoutFields['hierarchical']);
|
||||||
|
self::assertSame(['title', 'editor'], $withoutFields['supports']);
|
||||||
|
self::assertTrue($withoutFields['divi']);
|
||||||
|
self::assertSame(5, $withoutFields['count']);
|
||||||
|
self::assertSame([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]], $withoutFields['taxonomies']);
|
||||||
|
self::assertArrayNotHasKey('fields', $withoutFields);
|
||||||
|
self::assertSame('footnotes', $withFields['fields'][0]['key']);
|
||||||
|
self::assertNull($schema->describe('et_pb_layout', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?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\RegisteredMetaSource;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
final class RegisteredMetaSourceTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @return array<string, array<string, mixed>> */
|
||||||
|
private function fieldsByKey(): array
|
||||||
|
{
|
||||||
|
Functions\when('get_registered_meta_keys')->alias(static function (string $type, string $sub = ''): array {
|
||||||
|
if ($sub === '') {
|
||||||
|
return ['footnotes' => ['type' => 'string', 'single' => true, 'show_in_rest' => true]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'_internal' => ['type' => 'integer', 'single' => true, 'show_in_rest' => false],
|
||||||
|
'tags_list' => ['type' => 'array', 'single' => false, 'description' => 'Liste'],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
Functions\when('is_protected_meta')->alias(static fn (string $key): bool => str_starts_with($key, '_'));
|
||||||
|
|
||||||
|
$fields = (new RegisteredMetaSource())->fieldsFor('event');
|
||||||
|
|
||||||
|
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDescribesGlobalAndSubtypeMetaKeys(): void
|
||||||
|
{
|
||||||
|
$fields = $this->fieldsByKey();
|
||||||
|
|
||||||
|
self::assertSame(['footnotes', '_internal', 'tags_list'], array_keys($fields));
|
||||||
|
self::assertSame('string', $fields['footnotes']['type']);
|
||||||
|
self::assertSame(FieldDefinition::WRITABLE, $fields['footnotes']['status']);
|
||||||
|
self::assertSame('meta', $fields['footnotes']['source']);
|
||||||
|
self::assertFalse($fields['footnotes']['multiple']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProtectedMetaHiddenFromRestIsReadonly(): void
|
||||||
|
{
|
||||||
|
$fields = $this->fieldsByKey();
|
||||||
|
|
||||||
|
self::assertSame(FieldDefinition::READONLY, $fields['_internal']['status']);
|
||||||
|
self::assertSame('integer', $fields['_internal']['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonSingleMetaIsMultiple(): void
|
||||||
|
{
|
||||||
|
$fields = $this->fieldsByKey();
|
||||||
|
|
||||||
|
self::assertTrue($fields['tags_list']['multiple']);
|
||||||
|
self::assertSame('Liste', $fields['tags_list']['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadUsesSingleOrMultipleMeta(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_post_meta')->once()->with(5, 'footnotes', true)->andReturn('x');
|
||||||
|
Functions\expect('get_post_meta')->once()->with(5, 'tags_list', false)->andReturn(['a', 'b']);
|
||||||
|
$source = new RegisteredMetaSource();
|
||||||
|
|
||||||
|
self::assertSame('x', $source->read(5, new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')));
|
||||||
|
self::assertSame(
|
||||||
|
['a', 'b'],
|
||||||
|
$source->read(5, new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWriteUpdatesReplacesListsAndDeletesNull(): void
|
||||||
|
{
|
||||||
|
$single = new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable');
|
||||||
|
$multi = new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true);
|
||||||
|
$gone = new FieldDefinition('old', 'old', 'string', 'meta', 'writable');
|
||||||
|
Functions\expect('update_post_meta')->once()->with(5, 'footnotes', 'neu');
|
||||||
|
Functions\expect('delete_post_meta')->once()->with(5, 'tags_list');
|
||||||
|
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'a');
|
||||||
|
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'b');
|
||||||
|
Functions\expect('delete_post_meta')->once()->with(5, 'old');
|
||||||
|
|
||||||
|
(new RegisteredMetaSource())->write(
|
||||||
|
5,
|
||||||
|
'event',
|
||||||
|
['footnotes' => 'neu', 'tags_list' => ['a', 'b'], 'old' => null],
|
||||||
|
['footnotes' => $single, 'tags_list' => $multi, 'old' => $gone]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class SampledMetaSourceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testReturnsVisibleKeysOfRecentPostsAsUnknown(): void
|
||||||
|
{
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->expects('get_col')
|
||||||
|
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "post_type = 'lit_person'")
|
||||||
|
&& str_contains($sql, 'LIMIT 50')))
|
||||||
|
->andReturn(['_edit_lock', 'city', 'role']);
|
||||||
|
|
||||||
|
$fields = (new SampledMetaSource($db))->fieldsFor('lit_person');
|
||||||
|
|
||||||
|
self::assertSame(['city', 'role'], array_map(static fn (FieldDefinition $f): string => $f->key, $fields));
|
||||||
|
self::assertSame(FieldDefinition::UNKNOWN, $fields[0]->status);
|
||||||
|
self::assertSame('sample', $fields[0]->source);
|
||||||
|
self::assertSame('unknown', $fields[0]->type);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user