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'] = []; $_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', ] ); $registry->register( 'test', [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_description', 'zone' => 'cold', ] ); $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'] ); } // ── 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() ); } // ── 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'] ); } public function test_post_view_429_increments_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 ); $this->api->post_view( $req ); $stats = get_option( 'wpdo_rl_stats', [] ); $this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) ); } // ── 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'] ); } }