Files
2meet-data-optimizer/tests/integration/RestApiIntegrationTest.php
wpdev 336664473b
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 15s
Tests / PHP Lint (pull_request) Successful in 17s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m22s
Tests / PHPCS (pull_request) Successful in 1m12s
Tests / PHPStan (pull_request) Successful in 1m10s
fix(2meet-data-optimizer): keep registered fields out of the public listings REST endpoints
- show_in_rest defaulted to true, so GET /wpdo/v1/listings and /listings/{id} (public by design) returned every registered hot / cold field to anonymous visitors — e.g. infocards tmeetic_ig_access_token_enc / _token_expiry / _user_id and WooCommerce _purchase_note (confirmed on dev36)
- Fields are private unless registered with show_in_rest => true; protected (underscore) and credential-like keys (token, secret, password, api key, private key, credential, salt, nonce) are never exposed
- Zone A list path no longer returns whole rows when nothing is public, and filters / orderby accept public columns only
- Version 1.1.1; CHANGELOG, readme
- Verify: unit 690 (+3), integration 525 (+3 private-column tests, RED on 1.1.0), live suite rest 2/2 (RED on 1.1.0), all live 49/49, phpcs 0, stan OK
2026-09-26 22:22:40 +08:00

413 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* Integration tests for WPDO_REST_API against real MariaDB.
*
* Creates a dedicated wp_itest_wpdo_hot_restapi table, seeds data,
* and exercises all four REST handlers end-to-end.
*/
use PHPUnit\Framework\TestCase;
class RestApiIntegrationTest extends TestCase {
private static WPDO_REST_API $api;
/** Table name for this test suite (avoids collisions with other tests). */
private static string $hot_table;
private static string $warm_table;
public static function setUpBeforeClass(): void {
global $wpdb;
self::$api = new WPDO_REST_API();
self::$hot_table = $wpdb->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',
'show_in_rest' => true,
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_featured',
'zone' => 'hot',
'column' => 'rp_featured',
'type' => 'tinyint',
'show_in_rest' => true,
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_description',
'zone' => 'cold',
'show_in_rest' => true,
] );
// Registered but not public (1.1.1: fields are private by default).
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_cost',
'zone' => 'hot',
'column' => 'rp_cost',
'type' => 'decimal',
] );
// 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,
rp_cost DECIMAL(10,2) 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"
);
// Posts table: listings are filtered by post_status against it.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query(
"CREATE TABLE `{$wpdb->posts}` (
ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_type VARCHAR(20) NOT NULL DEFAULT 'post',
post_status VARCHAR(20) NOT NULL DEFAULT 'publish',
PRIMARY KEY (ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Seed 5 published rows, plus draft/private/trash rows (ids 6-8) and
// a published post of another type (id 9) that must never be listed.
$statuses = array( 6 => 'draft', 7 => 'private', 8 => 'trash' );
for ( $i = 1; $i <= 8; $i++ ) {
$price = $i * 100;
$featured = $i % 2;
$status = $statuses[ $i ] ?? 'publish';
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
$cost = 1000 - $price;
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" );
}
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
// 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 . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
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.
$GLOBALS['_wp_registered_post_types'] = [ 'restapi' => true ];
// 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_never_return_a_private_column(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
foreach ( self::$api->get_listings( $req )->get_data() as $item ) {
$this->assertArrayNotHasKey( 'rp_cost', $item );
}
}
public function test_private_column_cannot_be_used_as_a_filter(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_cost_min', 800 );
$this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' );
}
public function test_private_column_cannot_be_used_for_ordering(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'orderby', 'rp_cost' );
$req->set_param( 'order', 'ASC' );
// rp_cost descends as post_id rises; ignoring it falls back to post_id ASC.
$this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) );
}
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 );
}
public function test_listings_excludes_unpublished_posts(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 20 );
$response = self::$api->get_listings( $req );
$ids = array_column( $response->get_data(), 'id' );
sort( $ids );
$this->assertSame( array( 1, 2, 3, 4, 5 ), $ids, 'draft/private/trash and other post types must not be listed.' );
$this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_filters_still_apply_with_status_join(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 400 );
$req->set_param( 'per_page', 20 );
$ids = array_column( self::$api->get_listings( $req )->get_data(), 'id' );
sort( $ids );
$this->assertSame( array( 4, 5 ), $ids );
}
public function test_listings_rejects_unregistered_post_type(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'nope' );
$this->assertSame( 400, self::$api->get_listings( $req )->get_status() );
}
// ── 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() );
}
}