Files
2meet-data-optimizer/tests/unit/RestApiTest.php
T
wpdev 9dc4516ca0 fix(2meet-data-optimizer): filter REST listings collection by post_status and post_type
- GET /wpdo/v1/listings rejects unregistered or non-viewable post types (400) and joins posts so Zone A only lists published posts of that type
- WP_Query fallback now returns only show_in_rest hot columns, matching the Zone A path
- Verify: core unit 605 / integration 432 OK (new RestApi tests); 1M bench: page 7ms, total COUNT 0.12s → 1.7s (tracked in PR-030)
- PR: PR-015
2026-09-25 23:10:46 +08:00

335 lines
13 KiB
PHP

<?php
/**
* Unit tests for WPDO_REST_API.
*
* Covers route registration, handler logic, permission callback,
* filter param extraction, and both zone-active + fallback paths.
*/
use PHPUnit\Framework\TestCase;
class RestApiTest extends TestCase {
private WPDO_REST_API $api;
protected function setUp(): void {
$this->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',
] );
$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() );
}
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' ) );
$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'] );
}
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'] );
}
}