Commit 53ae1927594 for woocommerce
commit 53ae192759458f448bf850cdfb472f390bea14fe
Author: Bartosz Budzanowski <bartosz.budzanowski@automattic.com>
Date: Mon Mar 23 20:50:50 2026 +0100
Fix invalid JSON Schema in MCP tool definitions (#63218)
* Fix invalid JSON Schema in MCP tool definitions
Fixes two schema validation issues that cause MCP clients to reject
WooCommerce tool definitions:
- Convert 'type: date-time' to 'type: string, format: date-time' per
JSON Schema spec (#62764)
- Deduplicate enum arrays to fix duplicate orderby values (#62034)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changelog entry for MCP JSON Schema fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix additional JSON Schema issues: invalid types, recursive sanitization, output schema
Address community-reported gaps in MCP schema sanitization:
- Handle 'mixed' type by omitting (any type per JSON Schema spec)
- Handle 'action' type by converting to 'object'
- Remove any unrecognized type values
- Recursively sanitize nested properties and items
- Sanitize output schema via get_output_schema()
- Expand test coverage from 8 to 15 tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Add changefile(s) from automation for the following project(s): woocommerce
* Handle array types and improve enum deduplication in MCP schema sanitization
- Support array type values (e.g. ['string', 'null']) for nullable fields:
normalize each element, deduplicate, collapse single-element arrays
- Skip non-string values in type arrays
- Improve enum deduplication using JSON fingerprinting to correctly handle
mixed scalar types (1 vs '1'), nulls, and complex values (arrays/objects)
- Extract normalize_type() and dedupe_enum() as reusable methods
- Add 8 new tests (23 total, 51 assertions)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Strip boolean required from nested schema properties
JSON Schema draft 2020-12 requires `required` to be an array of strings,
but WordPress REST API uses `required: true` as a boolean per-field.
The top-level sanitizer already converts these, but nested properties
(e.g., gift_cards.items.properties.code) were passed through unchanged,
causing Claude API to reject the tool schema.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix lint: rename reserved keyword parameter, align array arrows
- Rename $enum parameter to $values in dedupe_enum() (enum is reserved in PHP 8.1+)
- Align array double arrows in test fixture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Lift nested boolean required to parent required array
Instead of just stripping `required: true` from nested properties,
collect them into a proper `required` array on the parent object.
This preserves the required field semantics from the WordPress REST
API schema in valid JSON Schema format.
Addresses review feedback from @peterfabian.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63218-fix-mcp-json-schema-validation b/plugins/woocommerce/changelog/63218-fix-mcp-json-schema-validation
new file mode 100644
index 00000000000..ea41ada5d56
--- /dev/null
+++ b/plugins/woocommerce/changelog/63218-fix-mcp-json-schema-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix invalid JSON Schema in MCP tool definitions: convert date-time type to proper string+format, deduplicate enum values, handle mixed/action/unrecognized types, normalize array types, recursively sanitize nested schemas, sanitize output schemas, and strip boolean required from nested properties.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/fix-mcp-json-schema-validation b/plugins/woocommerce/changelog/fix-mcp-json-schema-validation
new file mode 100644
index 00000000000..1377ce31744
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-mcp-json-schema-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix invalid JSON Schema in MCP tool definitions: convert date-time type to proper string+format and deduplicate enum values.
diff --git a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
index 8bc7ec1e543..17eaf8aacf9 100644
--- a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
+++ b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
@@ -156,6 +156,13 @@ class RestAbilityFactory {
return array( 'type' => 'object' );
}
+ /**
+ * Valid JSON Schema types.
+ *
+ * @var array
+ */
+ private static $valid_types = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' );
+
/**
* Sanitize WordPress REST args to valid JSON Schema format.
*
@@ -164,6 +171,9 @@ class RestAbilityFactory {
* - Converting 'required' from boolean-per-field to array-of-names
* - Removing WordPress-specific non-schema fields
* - Preserving valid JSON Schema properties
+ * - Converting invalid types (date-time, mixed, action) to valid JSON Schema
+ * - Recursively sanitizing nested properties and items
+ * - Deduplicating enum values
*
* @param array $args WordPress REST API arguments array.
* @return array Valid JSON Schema object.
@@ -175,9 +185,9 @@ class RestAbilityFactory {
foreach ( $args as $key => $arg ) {
$property = array();
- // Copy valid JSON Schema fields.
+ // Copy valid JSON Schema fields, normalizing types.
if ( isset( $arg['type'] ) ) {
- $property['type'] = $arg['type'];
+ $property = self::normalize_type( $property, $arg['type'] );
}
if ( isset( $arg['description'] ) ) {
$property['description'] = $arg['description'];
@@ -186,10 +196,10 @@ class RestAbilityFactory {
$property['default'] = $arg['default'];
}
if ( isset( $arg['enum'] ) ) {
- $property['enum'] = array_values( $arg['enum'] );
+ $property['enum'] = self::dedupe_enum( $arg['enum'] );
}
if ( isset( $arg['items'] ) ) {
- $property['items'] = $arg['items'];
+ $property['items'] = self::sanitize_schema( $arg['items'] );
}
if ( isset( $arg['minimum'] ) ) {
$property['minimum'] = $arg['minimum'];
@@ -197,11 +207,11 @@ class RestAbilityFactory {
if ( isset( $arg['maximum'] ) ) {
$property['maximum'] = $arg['maximum'];
}
- if ( isset( $arg['format'] ) ) {
+ if ( isset( $arg['format'] ) && ! isset( $property['format'] ) ) {
$property['format'] = $arg['format'];
}
if ( isset( $arg['properties'] ) ) {
- $property['properties'] = $arg['properties'];
+ $property['properties'] = self::sanitize_schema_properties( $arg['properties'] );
}
// Convert readonly to readOnly (JSON Schema format).
@@ -229,6 +239,152 @@ class RestAbilityFactory {
return $schema;
}
+ /**
+ * Recursively sanitize a JSON Schema node.
+ *
+ * Fixes invalid types, deduplicates enums, and recurses into
+ * nested properties and items.
+ *
+ * @param array $schema A JSON Schema node.
+ * @return array Sanitized schema node.
+ */
+ private static function sanitize_schema( array $schema ): array {
+ if ( isset( $schema['type'] ) ) {
+ $schema = self::normalize_type( $schema, $schema['type'] );
+ }
+
+ if ( isset( $schema['enum'] ) ) {
+ $schema['enum'] = self::dedupe_enum( $schema['enum'] );
+ }
+
+ // Remove WordPress-style boolean 'required' — JSON Schema requires an array.
+ if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
+ unset( $schema['required'] );
+ }
+
+ if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
+ // Collect required fields from nested boolean 'required' before sanitizing.
+ $required = array();
+ foreach ( $schema['properties'] as $key => $property ) {
+ if ( is_array( $property ) && isset( $property['required'] ) && true === $property['required'] ) {
+ $required[] = $key;
+ }
+ }
+ if ( ! empty( $required ) ) {
+ $schema['required'] = isset( $schema['required'] ) && is_array( $schema['required'] )
+ ? array_values( array_unique( array_merge( $schema['required'], $required ) ) )
+ : $required;
+ }
+
+ $schema['properties'] = self::sanitize_schema_properties( $schema['properties'] );
+ }
+
+ if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
+ $schema['items'] = self::sanitize_schema( $schema['items'] );
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Sanitize a map of JSON Schema properties.
+ *
+ * @param array $properties Map of property name to schema.
+ * @return array Sanitized properties map.
+ */
+ private static function sanitize_schema_properties( array $properties ): array {
+ foreach ( $properties as $key => $property ) {
+ if ( is_array( $property ) ) {
+ $properties[ $key ] = self::sanitize_schema( $property );
+ }
+ }
+ return $properties;
+ }
+
+ /**
+ * Normalize a schema type value.
+ *
+ * Handles both string types ('string', 'date-time', etc.) and
+ * array types (['string', 'null']) used for nullable fields.
+ *
+ * @param array $schema The schema node being built.
+ * @param string|array $type The type value to normalize.
+ * @return array Schema with normalized type (or type removed if all invalid).
+ */
+ private static function normalize_type( array $schema, $type ): array {
+ if ( is_string( $type ) ) {
+ if ( 'date-time' === $type ) {
+ $schema['type'] = 'string';
+ if ( ! isset( $schema['format'] ) ) {
+ $schema['format'] = 'date-time';
+ }
+ } elseif ( 'action' === $type ) {
+ $schema['type'] = 'object';
+ } elseif ( in_array( $type, self::$valid_types, true ) ) {
+ $schema['type'] = $type;
+ } else {
+ unset( $schema['type'] );
+ }
+ return $schema;
+ }
+
+ if ( is_array( $type ) ) {
+ $normalized = array();
+ foreach ( $type as $single ) {
+ if ( ! is_string( $single ) ) {
+ continue;
+ }
+ if ( 'date-time' === $single ) {
+ $single = 'string';
+ if ( ! isset( $schema['format'] ) ) {
+ $schema['format'] = 'date-time';
+ }
+ } elseif ( 'action' === $single ) {
+ $single = 'object';
+ } elseif ( ! in_array( $single, self::$valid_types, true ) ) {
+ continue;
+ }
+ $normalized[] = $single;
+ }
+ $normalized = array_values( array_unique( $normalized ) );
+ if ( empty( $normalized ) ) {
+ unset( $schema['type'] );
+ } elseif ( 1 === count( $normalized ) ) {
+ $schema['type'] = $normalized[0];
+ } else {
+ $schema['type'] = $normalized;
+ }
+ return $schema;
+ }
+
+ // Non-string, non-array type — remove it.
+ unset( $schema['type'] );
+ return $schema;
+ }
+
+ /**
+ * Remove duplicate enum values while preserving order.
+ *
+ * Uses JSON encoding for fingerprinting to correctly handle
+ * mixed scalar types (1 vs '1'), nulls, and complex values (arrays).
+ *
+ * @param array $values Enum values.
+ * @return array Deduplicated enum values.
+ */
+ private static function dedupe_enum( array $values ): array {
+ $seen = array();
+ $unique = array();
+ foreach ( $values as $value ) {
+ $fingerprint = wp_json_encode( $value );
+ if ( isset( $seen[ $fingerprint ] ) ) {
+ continue;
+ }
+ $seen[ $fingerprint ] = true;
+ $unique[] = $value;
+ }
+ return $unique;
+ }
+
/**
* Get output schema for operation.
*
@@ -238,7 +394,7 @@ class RestAbilityFactory {
*/
private static function get_output_schema( $controller, string $operation ): array {
if ( method_exists( $controller, 'get_item_schema' ) ) {
- $schema = $controller->get_item_schema();
+ $schema = self::sanitize_schema( $controller->get_item_schema() );
if ( 'list' === $operation ) {
// For list operations, return object wrapping array of items.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php
new file mode 100644
index 00000000000..0127134d910
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php
@@ -0,0 +1,635 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\REST;
+
+use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the RestAbilityFactory class.
+ *
+ * Focuses on schema sanitization logic in sanitize_args_to_schema().
+ */
+class RestAbilityFactoryTest extends WC_Unit_Test_Case {
+
+ /**
+ * Valid JSON Schema types per the spec.
+ */
+ private const VALID_JSON_SCHEMA_TYPES = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' );
+
+ /**
+ * Helper to invoke the private sanitize_args_to_schema method.
+ *
+ * @param array $args WordPress REST API arguments array.
+ * @return array Sanitized JSON Schema.
+ */
+ private function invoke_sanitize_args_to_schema( array $args ): array {
+ $reflection = new \ReflectionClass( RestAbilityFactory::class );
+ $method = $reflection->getMethod( 'sanitize_args_to_schema' );
+ $method->setAccessible( true );
+
+ return $method->invoke( null, $args );
+ }
+
+ /**
+ * Helper to invoke the private get_output_schema method.
+ *
+ * @param object $controller REST controller instance.
+ * @param string $operation Operation type.
+ * @return array Output schema.
+ */
+ private function invoke_get_output_schema( $controller, string $operation ): array {
+ $reflection = new \ReflectionClass( RestAbilityFactory::class );
+ $method = $reflection->getMethod( 'get_output_schema' );
+ $method->setAccessible( true );
+
+ return $method->invoke( null, $controller, $operation );
+ }
+
+ /**
+ * Recursively collect all 'type' values from a schema.
+ *
+ * @param array $schema JSON Schema array.
+ * @return array All type values found.
+ */
+ private function collect_all_types( array $schema ): array {
+ $types = array();
+
+ if ( isset( $schema['type'] ) ) {
+ if ( is_array( $schema['type'] ) ) {
+ $types = array_merge( $types, $schema['type'] );
+ } else {
+ $types[] = $schema['type'];
+ }
+ }
+
+ if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
+ foreach ( $schema['properties'] as $property ) {
+ if ( is_array( $property ) ) {
+ $types = array_merge( $types, $this->collect_all_types( $property ) );
+ }
+ }
+ }
+
+ if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
+ $types = array_merge( $types, $this->collect_all_types( $schema['items'] ) );
+ }
+
+ return $types;
+ }
+
+ /**
+ * Create a mock controller with a given item schema.
+ *
+ * @param array $item_schema The schema to return from get_item_schema.
+ * @return object Mock controller.
+ */
+ private function create_mock_controller_with_item_schema( array $item_schema ): object {
+ return new class( $item_schema ) {
+ /**
+ * The schema.
+ *
+ * @var array
+ */
+ private array $schema;
+
+ /**
+ * Constructor.
+ *
+ * @param array $schema The schema.
+ */
+ public function __construct( array $schema ) {
+ $this->schema = $schema;
+ }
+
+ /**
+ * Get item schema.
+ *
+ * @return array
+ */
+ public function get_item_schema(): array {
+ return $this->schema;
+ }
+ };
+ }
+
+ // ── Bug 1: date-time type conversion (issue #62764) ──
+
+ /**
+ * @testdox Should convert date-time type to string with date-time format.
+ */
+ public function test_converts_date_time_type_to_string_with_format(): void {
+ $args = array(
+ 'date_created' => array(
+ 'type' => 'date-time',
+ 'description' => 'The date the resource was created.',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( 'string', $schema['properties']['date_created']['type'], 'date-time should be converted to string type' );
+ $this->assertSame( 'date-time', $schema['properties']['date_created']['format'], 'date-time format should be set' );
+ }
+
+ /**
+ * @testdox Should preserve explicit format when converting date-time type.
+ */
+ public function test_date_time_conversion_preserves_explicit_format(): void {
+ $args = array(
+ 'date_field' => array(
+ 'type' => 'date-time',
+ 'format' => 'date-time',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( 'string', $schema['properties']['date_field']['type'] );
+ $this->assertSame( 'date-time', $schema['properties']['date_field']['format'] );
+ }
+
+ // ── Bug 2: duplicate enum values (issue #62034) ──
+
+ /**
+ * @testdox Should deduplicate enum values.
+ */
+ public function test_deduplicates_enum_values(): void {
+ $args = array(
+ 'orderby' => array(
+ 'type' => 'string',
+ 'enum' => array( 'date', 'id', 'title', 'price', 'popularity', 'rating', 'price', 'popularity', 'rating' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $enum = $schema['properties']['orderby']['enum'];
+ $this->assertSame( array_values( array_unique( $enum ) ), $enum, 'Enum should not contain duplicate values' );
+ $this->assertCount( 6, $enum );
+ }
+
+ /**
+ * @testdox Should reindex enum values after deduplication.
+ */
+ public function test_enum_values_are_reindexed(): void {
+ $args = array(
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => array( 'draft', 'published', 'draft' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'draft', 'published' ), $schema['properties']['status']['enum'] );
+ }
+
+ // ── Gap 1: invalid types like 'mixed' and 'action' ──
+
+ /**
+ * @testdox Should handle type mixed by removing the type key.
+ */
+ public function test_handles_mixed_type(): void {
+ $args = array(
+ 'value' => array(
+ 'type' => 'mixed',
+ 'description' => 'Meta value.',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertArrayNotHasKey( 'type', $schema['properties']['value'], 'mixed type should be removed' );
+ $this->assertSame( 'Meta value.', $schema['properties']['value']['description'] );
+ }
+
+ /**
+ * @testdox Should handle type action by converting to object.
+ */
+ public function test_handles_action_type(): void {
+ $args = array(
+ 'line_items' => array(
+ 'type' => 'action',
+ 'description' => 'Line items.',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( 'object', $schema['properties']['line_items']['type'], 'action type should be converted to object' );
+ }
+
+ /**
+ * @testdox Should remove any unrecognized type value.
+ */
+ public function test_handles_unrecognized_type(): void {
+ $args = array(
+ 'field' => array(
+ 'type' => 'foobar',
+ 'description' => 'Unknown type field.',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertArrayNotHasKey( 'type', $schema['properties']['field'], 'Unrecognized type should be removed' );
+ }
+
+ /**
+ * @testdox Should preserve all valid JSON Schema types.
+ */
+ public function test_preserves_valid_types(): void {
+ $args = array();
+ foreach ( self::VALID_JSON_SCHEMA_TYPES as $type ) {
+ $args[ $type . '_field' ] = array( 'type' => $type );
+ }
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ foreach ( self::VALID_JSON_SCHEMA_TYPES as $type ) {
+ $this->assertSame( $type, $schema['properties'][ $type . '_field' ]['type'], "Valid type '$type' should be preserved" );
+ }
+ }
+
+ /**
+ * @testdox Should collect required fields correctly.
+ */
+ public function test_collects_required_fields(): void {
+ $args = array(
+ 'name' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'price' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'sku' => array(
+ 'type' => 'string',
+ 'required' => false,
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertArrayHasKey( 'required', $schema );
+ $this->assertContains( 'name', $schema['required'] );
+ $this->assertContains( 'price', $schema['required'] );
+ $this->assertNotContains( 'sku', $schema['required'] );
+ }
+
+ // ── Gap 3: recursive sanitization of nested properties/items ──
+
+ /**
+ * @testdox Should recursively sanitize nested properties with invalid types.
+ */
+ public function test_sanitizes_nested_properties(): void {
+ $args = array(
+ 'meta_data' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'key' => array( 'type' => 'string' ),
+ 'value' => array( 'type' => 'mixed' ),
+ ),
+ ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $all_types = $this->collect_all_types( $schema );
+ $this->assertNotContains( 'mixed', $all_types, 'Nested mixed type should be sanitized' );
+ }
+
+ /**
+ * @testdox Should recursively sanitize date-time in nested items.
+ */
+ public function test_sanitizes_nested_date_time(): void {
+ $args = array(
+ 'dates' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'created_at' => array( 'type' => 'date-time' ),
+ 'updated_at' => array( 'type' => 'date-time' ),
+ ),
+ ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $all_types = $this->collect_all_types( $schema );
+ $this->assertNotContains( 'date-time', $all_types, 'Nested date-time type should be converted' );
+
+ $created = $schema['properties']['dates']['items']['properties']['created_at'];
+ $this->assertSame( 'string', $created['type'] );
+ $this->assertSame( 'date-time', $created['format'] );
+ }
+
+ /**
+ * @testdox Should recursively deduplicate nested enums.
+ */
+ public function test_sanitizes_nested_enums(): void {
+ $args = array(
+ 'filter' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => array( 'active', 'inactive', 'active' ),
+ ),
+ ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $enum = $schema['properties']['filter']['properties']['status']['enum'];
+ $this->assertCount( 2, $enum, 'Nested enum should be deduplicated' );
+ $this->assertSame( array( 'active', 'inactive' ), $enum );
+ }
+
+ // ── Gap 2: output schema sanitization ──
+
+ /**
+ * @testdox Should sanitize output schema types for get operations.
+ */
+ public function test_sanitizes_output_schema_types(): void {
+ $controller = $this->create_mock_controller_with_item_schema(
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'date_created' => array( 'type' => 'date-time' ),
+ 'meta_data' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'value' => array( 'type' => 'mixed' ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ $schema = $this->invoke_get_output_schema( $controller, 'get' );
+
+ $all_types = $this->collect_all_types( $schema );
+ $this->assertNotContains( 'date-time', $all_types, 'Output schema should not contain date-time type' );
+ $this->assertNotContains( 'mixed', $all_types, 'Output schema should not contain mixed type' );
+ }
+
+ /**
+ * @testdox Should sanitize output schema types for list operations.
+ */
+ public function test_sanitizes_output_schema_for_list_operations(): void {
+ $controller = $this->create_mock_controller_with_item_schema(
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'date_created' => array( 'type' => 'date-time' ),
+ ),
+ )
+ );
+
+ $schema = $this->invoke_get_output_schema( $controller, 'list' );
+
+ $all_types = $this->collect_all_types( $schema );
+ $this->assertNotContains( 'date-time', $all_types, 'Output schema for list should not contain date-time type' );
+ }
+
+ // ── Array types (nullable fields) ──
+
+ /**
+ * @testdox Should normalize array type with valid types preserved.
+ */
+ public function test_normalizes_array_type_with_valid_types(): void {
+ $args = array(
+ 'name' => array(
+ 'type' => array( 'string', 'null' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'string', 'null' ), $schema['properties']['name']['type'], 'Valid array types should be preserved' );
+ }
+
+ /**
+ * @testdox Should filter invalid types from array type and keep valid ones.
+ */
+ public function test_filters_invalid_types_from_array_type(): void {
+ $args = array(
+ 'value' => array(
+ 'type' => array( 'mixed', 'string', 'null' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'string', 'null' ), $schema['properties']['value']['type'], 'Invalid types should be filtered from array' );
+ }
+
+ /**
+ * @testdox Should convert date-time in array type to string and set format.
+ */
+ public function test_converts_date_time_in_array_type(): void {
+ $args = array(
+ 'created' => array(
+ 'type' => array( 'date-time', 'null' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'string', 'null' ), $schema['properties']['created']['type'] );
+ $this->assertSame( 'date-time', $schema['properties']['created']['format'] );
+ }
+
+ /**
+ * @testdox Should convert action in array type to object.
+ */
+ public function test_converts_action_in_array_type(): void {
+ $args = array(
+ 'field' => array(
+ 'type' => array( 'action', 'null' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'object', 'null' ), $schema['properties']['field']['type'] );
+ }
+
+ /**
+ * @testdox Should deduplicate array types after normalization.
+ */
+ public function test_deduplicates_array_types_after_normalization(): void {
+ $args = array(
+ 'field' => array(
+ 'type' => array( 'date-time', 'string' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( 'string', $schema['properties']['field']['type'], 'Should collapse to single type after dedup' );
+ }
+
+ /**
+ * @testdox Should remove type key when all array types are invalid.
+ */
+ public function test_removes_type_when_all_array_types_invalid(): void {
+ $args = array(
+ 'field' => array(
+ 'type' => array( 'mixed', 'foobar' ),
+ 'description' => 'All bad types.',
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertArrayNotHasKey( 'type', $schema['properties']['field'], 'Should remove type when all array types are invalid' );
+ $this->assertSame( 'All bad types.', $schema['properties']['field']['description'] );
+ }
+
+ /**
+ * @testdox Should handle non-string values in array type.
+ */
+ public function test_skips_non_string_values_in_array_type(): void {
+ $args = array(
+ 'field' => array(
+ 'type' => array( 'string', 123, null, 'integer' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( array( 'string', 'integer' ), $schema['properties']['field']['type'], 'Non-string values in type array should be skipped' );
+ }
+
+ // ── Robust enum deduplication ──
+
+ /**
+ * @testdox Should deduplicate enum with mixed scalar and complex values.
+ */
+ public function test_deduplicates_enum_with_mixed_value_types(): void {
+ $args = array(
+ 'value' => array(
+ 'type' => 'string',
+ 'enum' => array(
+ 1,
+ '1',
+ null,
+ null,
+ array( 'a' => 1 ),
+ array( 'a' => 1 ),
+ array( 'a' => 2 ),
+ ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $enum = $schema['properties']['value']['enum'];
+ $this->assertCount( 5, $enum, 'Should have 5 unique values: 1, "1", null, {a:1}, {a:2}' );
+ $this->assertSame(
+ array( 1, '1', null, array( 'a' => 1 ), array( 'a' => 2 ) ),
+ $enum
+ );
+ }
+
+ // ── Nested required boolean conversion ──
+
+ /**
+ * @testdox Should lift boolean required from nested properties to parent required array.
+ */
+ public function test_lifts_nested_boolean_required_to_parent_array(): void {
+ $args = array(
+ 'gift_cards' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'code' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'amount' => array(
+ 'type' => 'number',
+ 'required' => false,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $items = $schema['properties']['gift_cards']['items'];
+
+ $this->assertArrayNotHasKey( 'required', $items['properties']['code'], 'Boolean required should be removed from property' );
+ $this->assertArrayNotHasKey( 'required', $items['properties']['amount'], 'Boolean required should be removed from property' );
+
+ $this->assertArrayHasKey( 'required', $items, 'Parent object should have required array' );
+ $this->assertContains( 'code', $items['required'], 'code should be in parent required array' );
+ $this->assertNotContains( 'amount', $items['required'], 'amount should not be in parent required array' );
+ }
+
+ // ── Realistic scenario ──
+
+ /**
+ * @testdox Should sanitize realistic collection params with multiple issues.
+ */
+ public function test_sanitizes_realistic_collection_params(): void {
+ $args = array(
+ 'after' => array(
+ 'type' => 'date-time',
+ 'description' => 'Limit response to resources published after a given date.',
+ ),
+ 'before' => array(
+ 'type' => 'date-time',
+ 'description' => 'Limit response to resources published before a given date.',
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'default' => 10,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ 'orderby' => array(
+ 'type' => 'string',
+ 'default' => 'date',
+ 'enum' => array( 'date', 'id', 'title', 'slug', 'price', 'popularity', 'rating', 'menu_order', 'price', 'popularity', 'rating' ),
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'default' => 'any',
+ 'enum' => array( 'any', 'draft', 'pending', 'private', 'publish' ),
+ ),
+ );
+
+ $schema = $this->invoke_sanitize_args_to_schema( $args );
+
+ $this->assertSame( 'string', $schema['properties']['after']['type'] );
+ $this->assertSame( 'date-time', $schema['properties']['after']['format'] );
+ $this->assertSame( 'string', $schema['properties']['before']['type'] );
+ $this->assertSame( 'date-time', $schema['properties']['before']['format'] );
+ $this->assertSame( 'integer', $schema['properties']['per_page']['type'] );
+ $this->assertArrayNotHasKey( 'format', $schema['properties']['per_page'] );
+
+ $orderby_enum = $schema['properties']['orderby']['enum'];
+ $this->assertCount( count( array_unique( $orderby_enum ) ), $orderby_enum, 'orderby enum should have no duplicates' );
+ $this->assertCount( 8, $orderby_enum );
+ $this->assertCount( 5, $schema['properties']['status']['enum'] );
+ }
+}