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 ),
+ );
+ }
+}