api = new WPDO_REST_API(); // Reset globals. $GLOBALS['_wp_options'] = []; $GLOBALS['_wp_postmeta'] = []; $GLOBALS['_wp_post_types'] = []; $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; $GLOBALS['_wp_query_posts'] = []; $GLOBALS['_wp_registered_post_types'] = [ 'hp_listing' => true ]; $_COOKIE = []; // Reset Feature Flags cache via reflection. $ff_ref = new ReflectionClass( WPDO_Feature_Flags::class ); $ff_prop = $ff_ref->getProperty( 'cache' ); $ff_prop->setAccessible( true ); $ff_prop->setValue( null, null ); // Reset Schema Registry singleton. $ref = new ReflectionClass( WPDO_Schema_Registry::class ); $prop = $ref->getProperty( 'instance' ); $prop->setAccessible( true ); $prop->setValue( null, null ); } // ── Route registration ──────────────────────────────────────────────────── public function test_register_routes_calls_register_rest_route(): void { // register_rest_route is stubbed to return true — just confirm no exception. $this->api->register_routes(); $this->assertTrue( true ); } // ── Permission callback ─────────────────────────────────────────────────── public function test_require_manage_options_false_when_not_admin(): void { $GLOBALS['_wp_current_user_can']['manage_options'] = false; $this->assertFalse( $this->api->require_manage_options() ); } public function test_require_manage_options_true_when_admin(): void { $GLOBALS['_wp_current_user_can']['manage_options'] = true; $this->assertTrue( $this->api->require_manage_options() ); } // ── get_status ──────────────────────────────────────────────────────────── public function test_get_status_returns_version_and_engine(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); $response = $this->api->get_status( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( WPDO_VERSION, $data['version'] ); $this->assertSame( 'mysql', $data['engine'] ); $this->assertArrayHasKey( 'fields', $data ); $this->assertArrayHasKey( 'modules', $data ); } // ── get_listing (single) ────────────────────────────────────────────────── public function test_get_listing_404_when_post_not_found(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' ); $req->set_param( 'id', 9999 ); $response = $this->api->get_listing( $req ); $this->assertSame( 404, $response->get_status() ); } public function test_get_listing_returns_postmeta_when_zones_idle(): void { $GLOBALS['_wp_post_types'][42] = 'hp_listing'; $GLOBALS['_wp_postmeta'][42]['hp_price'] = '500'; $GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc'; // Register hot + cold fields. $registry = WPDO_Schema_Registry::instance(); $registry->register( 'test', [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'type' => 'decimal', 'show_in_rest' => true, ] ); $registry->register( 'test', [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_description', 'zone' => 'cold', 'show_in_rest' => true, ] ); $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' ); $req->set_param( 'id', 42 ); $response = $this->api->get_listing( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 42, $data['id'] ); $this->assertSame( 'hp_listing', $data['post_type'] ); $this->assertSame( '500', $data['hp_price'] ); $this->assertSame( 'Test desc', $data['hp_description'] ); } // ── public field exposure (1.1.1) ───────────────────────────────────────── /** * Register fields on hp_listing #43 and GET it anonymously. * * @param array> $fields Field configs (post_type added). * @param array $meta Stored meta. * @return array Response data. */ private function listing_with( array $fields, array $meta ): array { $GLOBALS['_wp_post_types'][43] = 'hp_listing'; $GLOBALS['_wp_postmeta'][43] = $meta; foreach ( $fields as $field ) { WPDO_Schema_Registry::instance()->register( 'test', $field + array( 'post_type' => 'hp_listing' ) ); } $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/43' ); $req->set_param( 'id', 43 ); return $this->api->get_listing( $req )->get_data(); } public function test_registered_fields_are_not_public_unless_declared(): void { $data = $this->listing_with( array( array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ), array( 'meta_key' => 'hp_notes', 'zone' => 'cold' ), ), array( 'hp_price' => '500', 'hp_notes' => 'internal' ) ); $this->assertSame( array( 'id', 'post_type' ), array_keys( $data ) ); } public function test_protected_and_credential_keys_stay_private_even_when_declared_public(): void { $data = $this->listing_with( array( array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ), array( 'meta_key' => '_purchase_note', 'zone' => 'cold', 'show_in_rest' => true ), array( 'meta_key' => 'tmeetic_ig_access_token_enc', 'zone' => 'cold', 'show_in_rest' => true ), array( 'meta_key' => 'hp_api_key', 'zone' => 'hot', 'column' => 'hp_api_key', 'show_in_rest' => true ), array( 'meta_key' => 'hp_client_secret', 'zone' => 'cold', 'show_in_rest' => true ), ), array( 'hp_price' => '500', '_purchase_note' => 'x', 'tmeetic_ig_access_token_enc' => 'y', 'hp_api_key' => 'z', 'hp_client_secret' => 'w' ) ); $this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) ); } public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void { $private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' ); $public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' ); $fields = array(); $values = array(); foreach ( array_merge( $private, $public ) as $key ) { $fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true ); $values[ $key ] = 'v'; } $data = $this->listing_with( $fields, $values ); $this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) ); } public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void { $registry = WPDO_Schema_Registry::instance(); $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => '_hp_token', 'zone' => 'hot', 'column' => 'hp_token', 'show_in_rest' => true ) ); $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_draft', 'zone' => 'hot', 'column' => 'hp_draft' ) ); $this->assertSame( array( 'hp_price' ), $registry->get_rest_visible_hot_columns( 'hp_listing' ) ); } // ── get_stats ───────────────────────────────────────────────────────────── public function test_get_stats_404_when_post_not_found(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' ); $req->set_param( 'id', 9999 ); $response = $this->api->get_stats( $req ); $this->assertSame( 404, $response->get_status() ); } public function test_get_stats_returns_view_count(): void { $GLOBALS['_wp_post_types'][55] = 'hp_listing'; // Warm zone idle, falls back to postmeta. $GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17'; $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' ); $req->set_param( 'id', 55 ); $response = $this->api->get_stats( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 55, $data['post_id'] ); $this->assertIsInt( $data['view_count'] ); } // ── get_listings (WP_Query fallback) ───────────────────────────────────── public function test_get_listings_returns_200_via_wp_query_fallback(): void { // Zone idle → WP_Query path. $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'hp_listing' ); $req->set_param( 'per_page', 10 ); $req->set_param( 'page', 1 ); $response = $this->api->get_listings( $req ); $this->assertSame( 200, $response->get_status() ); $this->assertIsArray( $response->get_data() ); } public function test_default_post_type_is_post_and_filterable(): void { $this->assertSame( 'post', WPDO_REST_API::default_post_type() ); $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'][] = static fn() => 'hp_listing'; try { $this->assertSame( 'hp_listing', WPDO_REST_API::default_post_type() ); } finally { unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] ); } } public function test_get_listings_rejects_unregistered_post_type(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'no_such_type' ); $response = $this->api->get_listings( $req ); $this->assertSame( 400, $response->get_status() ); } public function test_get_listings_rejects_non_viewable_post_type(): void { $GLOBALS['_wp_registered_post_types']['hp_internal'] = false; $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'hp_internal' ); $response = $this->api->get_listings( $req ); $this->assertSame( 400, $response->get_status() ); } public function test_listings_fallback_only_returns_show_in_rest_columns(): void { $registry = WPDO_Schema_Registry::instance(); $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) ); $post = new stdClass(); $post->ID = 21; $post->post_type = 'hp_listing'; $GLOBALS['_wp_query_posts'] = array( $post ); $GLOBALS['_wp_postmeta'][21] = array( 'hp_price' => '99', 'hp_secret' => 'hidden' ); $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'hp_listing' ); $data = $this->api->get_listings( $req )->get_data(); $this->assertSame( '99', $data[0]['hp_price'] ); $this->assertArrayNotHasKey( 'hp_secret', $data[0] ); } // ── Pagination headers ──────────────────────────────────────────────────── public function test_listings_fallback_sets_pagination_headers(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'hp_listing' ); $req->set_param( 'per_page', 10 ); $req->set_param( 'page', 1 ); $response = $this->api->get_listings( $req ); $headers = $response->get_headers(); $this->assertArrayHasKey( 'X-WP-Total', $headers ); $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); } // ── post_view ───────────────────────────────────────────────────────────── public function test_post_view_403_without_nonce(): void { $GLOBALS['_wp_post_types'][10] = 'hp_listing'; $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' ); $req->set_param( 'id', 10 ); // No nonce set. $response = $this->api->post_view( $req ); $this->assertSame( 403, $response->get_status() ); } public function test_post_view_403_with_invalid_nonce(): void { $GLOBALS['_wp_post_types'][11] = 'hp_listing'; $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' ); $req->set_param( 'id', 11 ); $req->set_header( 'X-WP-Nonce', 'bad_nonce' ); $response = $this->api->post_view( $req ); $this->assertSame( 403, $response->get_status() ); } public function test_post_view_404_when_post_not_found(): void { $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' ); $req->set_param( 'id', 9999 ); $req->set_header( 'X-WP-Nonce', $nonce ); $response = $this->api->post_view( $req ); $this->assertSame( 404, $response->get_status() ); } public function test_post_view_returns_view_count(): void { $GLOBALS['_wp_post_types'][20] = 'hp_listing'; $GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5'; $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' ); $req->set_param( 'id', 20 ); $req->set_header( 'X-WP-Nonce', $nonce ); $response = $this->api->post_view( $req ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 20, $data['post_id'] ); $this->assertIsInt( $data['view_count'] ); } // ── post_view: rate limiting ────────────────────────────────────────────── public function test_post_view_success_sets_set_cookie_header(): void { $GLOBALS['_wp_post_types'][25] = 'hp_listing'; $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' ); $req->set_param( 'id', 25 ); $req->set_header( 'X-WP-Nonce', $nonce ); $response = $this->api->post_view( $req ); $this->assertSame( 200, $response->get_status() ); $this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() ); $this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] ); } public function test_post_view_429_when_ip_rate_limited(): void { $GLOBALS['_wp_post_types'][30] = 'hp_listing'; $nonce = wp_create_nonce( 'wp_rest' ); // First call succeeds and sets IP transient. $req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' ); $req1->set_param( 'id', 30 ); $req1->set_header( 'X-WP-Nonce', $nonce ); $resp1 = $this->api->post_view( $req1 ); $this->assertSame( 200, $resp1->get_status() ); // Second call (same IP, within TTL) must be rate-limited. $req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' ); $req2->set_param( 'id', 30 ); $req2->set_header( 'X-WP-Nonce', $nonce ); $resp2 = $this->api->post_view( $req2 ); $this->assertSame( 429, $resp2->get_status() ); $this->assertSame( 'too_many_requests', $resp2->get_data()['code'] ); } public function test_post_view_429_when_cookie_present(): void { $GLOBALS['_wp_post_types'][35] = 'hp_listing'; $_COOKIE['wpdo_view_35'] = '1'; $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' ); $req->set_param( 'id', 35 ); $req->set_header( 'X-WP-Nonce', $nonce ); $response = $this->api->post_view( $req ); $this->assertSame( 429, $response->get_status() ); $this->assertSame( 'too_many_requests', $response->get_data()['code'] ); } /** * Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous * traffic cannot turn every blocked request into an option write. */ public function test_post_view_429_samples_rate_limit_stats(): void { $GLOBALS['_wp_post_types'][40] = 'hp_listing'; $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block. $nonce = wp_create_nonce( 'wp_rest' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' ); $req->set_param( 'id', 40 ); $req->set_header( 'X-WP-Nonce', $nonce ); $writes = 0; for ( $i = 0; $i < 200; $i++ ) { $before = get_option( 'wpdo_rl_stats', [] ); $this->assertSame( 429, $this->api->post_view( $req )->get_status() ); $writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1; } $count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 ); $this->assertSame( 10 * $writes, $count, 'each sample adds 10' ); $this->assertGreaterThan( 0, $writes ); $this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' ); } // ── get_status: rate_limit_stats ───────────────────────────────────────── public function test_get_status_includes_rate_limit_stats(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); $data = $this->api->get_status( $req )->get_data(); $this->assertArrayHasKey( 'rate_limit_stats', $data ); $this->assertIsArray( $data['rate_limit_stats'] ); } }