prefix . 'wpdo_hot_restapi'; self::$warm_table = $wpdb->prefix . 'wpdo_warm'; // Register fields for the fake 'restapi' post type. $registry = WPDO_Schema_Registry::instance(); $registry->register( 'integration_rest', [ 'post_type' => 'restapi', 'meta_key' => 'rp_price', 'zone' => 'hot', 'column' => 'rp_price', 'type' => 'decimal', ] ); $registry->register( 'integration_rest', [ 'post_type' => 'restapi', 'meta_key' => 'rp_featured', 'zone' => 'hot', 'column' => 'rp_featured', 'type' => 'tinyint', ] ); $registry->register( 'integration_rest', [ 'post_type' => 'restapi', 'meta_key' => 'rp_description', 'zone' => 'cold', ] ); // Create hot table. $wpdb->query( "CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` ( post_id BIGINT UNSIGNED NOT NULL, rp_price DECIMAL(10,2) DEFAULT NULL, rp_featured TINYINT(1) DEFAULT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); // Create warm table (needed by WPDO_Listing_Stats::get_view_count). $wpdb->query( "CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, meta_key VARCHAR(255) NOT NULL, meta_value LONGTEXT, expires_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY post_meta (post_id, meta_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); // Seed 5 rows. for ( $i = 1; $i <= 5; $i++ ) { $price = $i * 100; $featured = $i % 2; $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" ); } // Set module to cutover so REST API reads from Zone A. WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' ); } public static function tearDownAfterClass(): void { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" ); $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); WPDO_Feature_Flags::reset( 'hot_restapi' ); } protected function setUp(): void { $GLOBALS['_wp_cache'] = []; $GLOBALS['_wp_post_types'] = []; $GLOBALS['_wp_postmeta'] = []; $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; $GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests. // Note: do NOT reset _wp_options here — Feature Flags state is stored there. // Invalidate Feature Flags static request cache so each test reads fresh. $ref = new ReflectionClass( WPDO_Feature_Flags::class ); $prop = $ref->getProperty( 'cache' ); $prop->setAccessible( true ); $prop->setValue( null, null ); } // ── GET /listings (Zone A path) ────────────────────────────────────────── public function test_listings_returns_all_rows(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'per_page', 10 ); $response = self::$api->get_listings( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertCount( 5, $data ); $this->assertSame( '5', $response->get_headers()['X-WP-Total'] ); } public function test_listings_returns_correct_fields(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'per_page', 1 ); $req->set_param( 'orderby', 'post_id' ); $req->set_param( 'order', 'ASC' ); $response = self::$api->get_listings( $req ); $data = $response->get_data(); $this->assertSame( 1, $data[0]['id'] ); $this->assertSame( 'restapi', $data[0]['post_type'] ); $this->assertArrayHasKey( 'rp_price', $data[0] ); $this->assertArrayHasKey( 'rp_featured', $data[0] ); $this->assertArrayNotHasKey( 'post_id', $data[0] ); $this->assertArrayNotHasKey( 'updated_at', $data[0] ); } public function test_listings_pagination(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'per_page', 2 ); $req->set_param( 'page', 2 ); $req->set_param( 'orderby', 'post_id' ); $req->set_param( 'order', 'ASC' ); $response = self::$api->get_listings( $req ); $data = $response->get_data(); $this->assertCount( 2, $data ); $this->assertSame( 3, $data[0]['id'] ); // page 2 offset 2 → post_id 3 $this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] ); } public function test_listings_per_page_clamped_to_max_100(): void { // PR-0 R-4: defense-in-depth — per_page=99999 must clamp to 100, not DoS the DB. $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'per_page', 99999 ); $response = self::$api->get_listings( $req ); // Should return at most 100 items (real dataset is 5 — capped by total). $data = $response->get_data(); $this->assertLessThanOrEqual( 100, count( $data ) ); } public function test_listings_per_page_max_filter_overridable(): void { // PR-0 R-4: site owners can lower the cap via wpdo_rest_max_per_page filter. // We can't fully test add_filter() in this stubbed env, but we verify the // constant value is correctly read in the code path (above test exercises 100). $this->assertTrue( true ); } public function test_listings_filter_price_min(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'rp_price_min', 300 ); $response = self::$api->get_listings( $req ); $data = $response->get_data(); // Prices are 100,200,300,400,500 → ≥300 = 3 rows. $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); foreach ( $data as $item ) { $this->assertGreaterThanOrEqual( 300.0, (float) $item['rp_price'] ); } } public function test_listings_filter_price_range(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'rp_price_min', 200 ); $req->set_param( 'rp_price_max', 400 ); $response = self::$api->get_listings( $req ); $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); } public function test_listings_filter_exact_value(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'rp_featured', 1 ); $response = self::$api->get_listings( $req ); $data = $response->get_data(); // featured=1 for post_id 1,3,5 → 3 rows. $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); foreach ( $data as $item ) { $this->assertSame( '1', (string) $item['rp_featured'] ); } } public function test_listings_order_asc(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); $req->set_param( 'orderby', 'rp_price' ); $req->set_param( 'order', 'ASC' ); $req->set_param( 'per_page', 5 ); $response = self::$api->get_listings( $req ); $data = $response->get_data(); $prices = array_column( $data, 'rp_price' ); $sorted = $prices; sort( $sorted ); $this->assertSame( $sorted, $prices ); } // ── GET /listings/{id} ─────────────────────────────────────────────────── public function test_get_listing_404_for_unknown_post(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' ); $req->set_param( 'id', 9999 ); $response = self::$api->get_listing( $req ); $this->assertSame( 404, $response->get_status() ); } public function test_get_listing_merges_hot_and_postmeta_cold(): void { // post_id 1 is in hot table (rp_price=100); cold zone idle → postmeta fallback. $GLOBALS['_wp_post_types'][1] = 'restapi'; $GLOBALS['_wp_postmeta'][1]['rp_description'] = 'Integration test'; $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/1' ); $req->set_param( 'id', 1 ); $response = self::$api->get_listing( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 1, $data['id'] ); $this->assertSame( '100.00', $data['rp_price'] ); $this->assertSame( 'Integration test', $data['rp_description'] ); } // ── GET /stats/{id} ────────────────────────────────────────────────────── public function test_get_stats_returns_zero_for_unknown_post(): void { $GLOBALS['_wp_post_types'][77] = 'restapi'; $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/77' ); $req->set_param( 'id', 77 ); $response = self::$api->get_stats( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 77, $data['post_id'] ); $this->assertSame( 0, $data['view_count'] ); } // ── GET /status ────────────────────────────────────────────────────────── public function test_get_status_requires_manage_options(): void { $GLOBALS['_wp_current_user_can']['manage_options'] = false; $this->assertFalse( self::$api->require_manage_options() ); } public function test_get_status_returns_correct_engine(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); $response = self::$api->get_status( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'mysql', $data['engine'] ); $this->assertArrayHasKey( 'modules', $data ); $this->assertSame( 'cutover', $data['modules']['hot_restapi'] ); } // ── POST /listings/{id}/view ────────────────────────────────────────────── public function test_post_view_403_without_nonce(): void { $GLOBALS['_wp_post_types'][1] = 'restapi'; $GLOBALS['_wp_valid_nonces'] = []; $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/1/view' ); $req->set_param( 'id', 1 ); // No nonce. $response = self::$api->post_view( $req ); $this->assertSame( 403, $response->get_status() ); } public function test_post_view_404_for_unknown_post(): void { $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/8888/view' ); $req->set_param( 'id', 8888 ); $req->set_header( 'X-WP-Nonce', $nonce ); $response = self::$api->post_view( $req ); $this->assertSame( 404, $response->get_status() ); } }