Files
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

297 lines
11 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'] = [];
$_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'] );
}
}