diff --git a/includes/class-wpvdb-rest.php b/includes/class-wpvdb-rest.php index 80a75e8..c5a12fc 100644 --- a/includes/class-wpvdb-rest.php +++ b/includes/class-wpvdb-rest.php @@ -912,6 +912,43 @@ public static function handle_metadata( \WP_REST_Request $request ) { // phpcs:i * @return int|\WP_Error Row ID, or WP_Error on validation failure or DB insert failure. */ public static function insert_embedding_row( $doc_id, $chunk_id, $chunk_content, $summary, $embedding, $model = '', $doc_type = 'post', $chunk_index = null ) { + /** + * Filters whether a missing or invalid chunk_index is a hard error. + * + * Default false preserves the legacy behavior (warn, then default to 0). + * Return true to reject a null, non-integer, negative, or out-of-range + * chunk_index with a WP_Error instead of silently storing 0, so caller + * regressions fail loudly. + * + * @param bool $strict Whether to reject an invalid chunk_index. Default false. + */ + $strict_chunk_index = (bool) apply_filters( 'wpvdb_strict_chunk_index', false ); + $valid_chunk_index = false; + if ( is_int( $chunk_index ) ) { + $valid_chunk_index = $chunk_index >= 0; + } elseif ( is_string( $chunk_index ) && preg_match( '/^\d+$/', $chunk_index ) ) { + $normalized_chunk_index = ltrim( $chunk_index, '0' ); + $max_chunk_index = (string) PHP_INT_MAX; + $valid_chunk_index = '' === $normalized_chunk_index + || strlen( $normalized_chunk_index ) < strlen( $max_chunk_index ) + || ( + strlen( $normalized_chunk_index ) === strlen( $max_chunk_index ) + && strcmp( $normalized_chunk_index, $max_chunk_index ) <= 0 + ); + } + if ( $strict_chunk_index && ! $valid_chunk_index ) { + Logger::error( 'insert_embedding_row rejected invalid chunk_index for doc_id=' . $doc_id ); + return new \WP_Error( + 'chunk_index_invalid', + 'Refused to store an embedding with a missing, non-integer, negative, or out-of-range chunk_index while strict mode is enabled.', + array( + 'doc_id' => $doc_id, + 'chunk_index' => $chunk_index, + 'status' => 400, + ) + ); + } + // Detect callers using the legacy 5/6/7-arg signature; fall back to 0 for backward // compatibility but emit a warning so the regression is visible. if ( null === $chunk_index ) { diff --git a/phpunit.xml b/phpunit.xml index a4dccfa..cefa8d7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,10 +17,10 @@ tests/integration - - tests/api - + + + includes @@ -38,4 +38,4 @@ - \ No newline at end of file + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0667da7..36382bb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,9 @@ echo "Using standalone testing mode.\n"; // Mock essential WordPress functions for testing +global $_wp_filters; +$_wp_filters = []; + if ( ! function_exists( 'add_action' ) ) { function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { return true; @@ -20,10 +23,45 @@ function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 if ( ! function_exists( 'add_filter' ) ) { function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { + global $_wp_filters; + if ( ! isset( $_wp_filters[ $tag ] ) ) { + $_wp_filters[ $tag ] = []; + } + if ( ! isset( $_wp_filters[ $tag ][ $priority ] ) ) { + $_wp_filters[ $tag ][ $priority ] = []; + } + $_wp_filters[ $tag ][ $priority ][] = [ + 'function' => $function_to_add, + 'accepted_args' => $accepted_args, + ]; return true; } } +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( $tag, $function_to_remove, $priority = 10 ) { + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ][ $priority ] ) ) { + return false; + } + + foreach ( $_wp_filters[ $tag ][ $priority ] as $index => $filter ) { + if ( $filter['function'] === $function_to_remove ) { + unset( $_wp_filters[ $tag ][ $priority ][ $index ] ); + if ( empty( $_wp_filters[ $tag ][ $priority ] ) ) { + unset( $_wp_filters[ $tag ][ $priority ] ); + } + if ( empty( $_wp_filters[ $tag ] ) ) { + unset( $_wp_filters[ $tag ] ); + } + return true; + } + } + + return false; + } +} + if ( ! function_exists( 'do_action' ) ) { function do_action( $hook_name, ...$args ) { // Mock implementation @@ -32,8 +70,22 @@ function do_action( $hook_name, ...$args ) { } if ( ! function_exists( 'apply_filters' ) ) { - function apply_filters( $tag, $value ) { - // Simple mock that just returns the value unchanged + function apply_filters( $tag, $value, ...$args ) { + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ] ) ) { + return $value; + } + + ksort( $_wp_filters[ $tag ] ); + $filter_args = array_merge( [ $value ], $args ); + foreach ( $_wp_filters[ $tag ] as $filters ) { + foreach ( $filters as $filter ) { + $accepted_args = max( 0, (int) $filter['accepted_args'] ); + $value = call_user_func_array( $filter['function'], array_slice( $filter_args, 0, $accepted_args ) ); + $filter_args[0] = $value; + } + } + return $value; } } @@ -183,7 +235,24 @@ function has_action( $tag, $function_to_check = false ) { if ( ! function_exists( 'has_filter' ) ) { function has_filter( $tag, $function_to_check = false ) { - return false; // Mock - always return false + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ] ) ) { + return false; + } + + if ( false === $function_to_check ) { + return true; + } + + foreach ( $_wp_filters[ $tag ] as $priority => $filters ) { + foreach ( $filters as $filter ) { + if ( $filter['function'] === $function_to_check ) { + return $priority; + } + } + } + + return false; } } @@ -266,6 +335,18 @@ function stripslashes_from_strings_only( $value ) { define( 'REST_REQUEST', false ); } +if ( ! function_exists( 'wpvdb_is_sqlite' ) ) { + function wpvdb_is_sqlite() { + return false; + } +} + +if ( ! function_exists( 'wpvdb_should_log_to_error_log' ) ) { + function wpvdb_should_log_to_error_log( $level = 'debug', $message = '', $context = array() ) { + return false; + } +} + if ( ! defined( 'OBJECT' ) ) { define( 'OBJECT', 'OBJECT' ); } @@ -435,8 +516,10 @@ public function suppress_errors( $suppress = true ) { require_once dirname( __DIR__ ) . '/includes/class-wpvdb-providers.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-models.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-settings.php'; +require_once dirname( __DIR__ ) . '/includes/class-wpvdb-cache.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-core.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-database.php'; +require_once dirname( __DIR__ ) . '/includes/class-wpvdb-rest.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-embedding-enqueuer.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-queue.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-security.php'; diff --git a/tests/filter-reset-hook.php b/tests/filter-reset-hook.php new file mode 100644 index 0000000..1fcef20 --- /dev/null +++ b/tests/filter-reset-hook.php @@ -0,0 +1,41 @@ +reset_filters(); + } + + /** + * Reset filters after each test finishes. + * + * @param string $test Test name. + * @param float $time Test runtime. + */ + public function executeAfterTest( string $test, float $time ): void { + $this->reset_filters(); + } + + /** + * Reset the standalone filter registry. + */ + private function reset_filters(): void { + $GLOBALS['_wp_filters'] = array(); + } +} diff --git a/tests/unit/RESTTest.php b/tests/unit/RESTTest.php new file mode 100644 index 0000000..7a1cd0c --- /dev/null +++ b/tests/unit/RESTTest.php @@ -0,0 +1,172 @@ +assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'chunk_index_invalid', $result->get_error_code() ); + $error_data = $result->get_error_data(); + $this->assertSame( 123, $error_data['doc_id'] ); + $this->assertSame( $chunk_index, $error_data['chunk_index'] ); + $this->assertSame( 400, $error_data['status'] ); + } + + /** + * Test strict chunk index mode accepts valid chunk index values. + * + * @dataProvider valid_chunk_indexes + * + * @param mixed $chunk_index Valid chunk index. + */ + public function test_strict_chunk_index_allows_valid_integer_values( $chunk_index ) { + $strict = function () { + return true; + }; + add_filter( 'wpvdb_strict_chunk_index', $strict, 10, 0 ); + + try { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + $chunk_index + ); + } finally { + remove_filter( 'wpvdb_strict_chunk_index', $strict ); + } + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $this->assertSame( (int) $chunk_index, $result->get_error_data()['chunk_index'] ); + } + + /** + * Test default mode does not reject invalid chunk index values. + * + * @dataProvider invalid_chunk_indexes + * + * @param mixed $chunk_index Invalid chunk index. + */ + public function test_default_chunk_index_mode_does_not_reject_invalid_values( $chunk_index ) { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + $chunk_index + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $error_data = $result->get_error_data(); + $this->assertSame( 123, $error_data['doc_id'] ); + $this->assertSame( 400, $error_data['status'] ); + } + + /** + * Test default mode keeps the legacy null-to-zero fallback. + */ + public function test_default_chunk_index_mode_preserves_null_fallback() { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + null + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $this->assertSame( 0, $result->get_error_data()['chunk_index'] ); + } + + /** + * Invalid chunk index provider. + * + * @return array + */ + public function invalid_chunk_indexes() { + return array( + 'null' => array( null ), + 'text' => array( 'abc' ), + 'negative int' => array( -1 ), + 'negative text' => array( '-1' ), + 'fraction float' => array( 1.5 ), + 'fraction text' => array( '1.5' ), + 'boolean' => array( true ), + 'false' => array( false ), + 'empty string' => array( '' ), + 'spaced number' => array( ' 1' ), + 'float one' => array( 1.0 ), + 'float zero' => array( 0.0 ), + 'plus one' => array( '+1' ), + 'exponent' => array( '1e3' ), + 'oversized' => array( PHP_INT_MAX . '0' ), + ); + } + + /** + * Valid chunk index provider. + * + * @return array + */ + public function valid_chunk_indexes() { + return array( + 'zero int' => array( 0 ), + 'positive int' => array( 3 ), + 'zero text' => array( '0' ), + 'number text' => array( '3' ), + 'max text' => array( (string) PHP_INT_MAX ), + ); + } +}