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
- 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
413 lines
15 KiB
PHP
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() );
|
|
}
|
|
|
|
}
|
|
|