From e33ae4e626720c9c0e7b9f046f52bf6db8820c3f Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 31 Jul 2026 10:41:13 +0800 Subject: [PATCH] =?UTF-8?q?test(boundary):=20=E6=96=B0=E5=A2=9E=20CoreBoun?= =?UTF-8?q?daryTest=EF=BC=8C=E4=B8=A6=E8=A3=9C=E5=AE=8C=20PR-I=20=E7=9A=84?= =?UTF-8?q?=E5=85=A9=E5=80=8B=E7=BC=BA=E6=BC=8F=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreBoundaryTest 靜態掃描核心 126 個生產檔,斷言每一處對 AddOn 類別的 static 呼叫都在同一個函式內有 class_exists() 守衛。上一個 commit 修的 TMDO_Listing_Stats fatal 就是這類缺陷,這個測試讓它不會再回來。 它當場又抓到 3 處同類違規(都是實際會 fatal 的路徑),一併修掉: - admin render_hpct_import():改印 admin notice 並 return - wp tmdo import-hpct:改 WP_CLI::error 明示需要 hivepress-addon - cli-post cleanup-hp-transients 其實早有守衛,是測試的行距啟發式太窄; 判斷範圍改成「同一個函式內」而非固定 12 行 負向驗證:暫時注入一處無守衛呼叫 → 測試如預期失敗;還原後回綠。 同時補完計畫階段 7 PR-I 列的兩個缺漏測試: - 核心 tests/unit/StandardPostInterceptorTest.php(10 tests) - HP AddOn tests/unit/ListingStatsTest.php(9 tests)——AddOn 的 unit bootstrap 先前刻意不載入真實 TMDO_Listing_Stats,改以 TMDO_TEST_SKIP_LISTING_STATS_STUB 常數讓它跳過核心的 stub - 核心 unit bootstrap 補 add_post_meta() stub(flush 路徑用得到) 核心 unit 451 → 587、HP AddOn 145 → 154。 --- admin/class-tmdo-admin.php | 8 + cli/class-tmdo-cli.php | 5 + tests/bootstrap.php | 13 +- tests/unit/CoreBoundaryTest.php | 152 ++++++++++++++ tests/unit/StandardPostInterceptorTest.php | 219 +++++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tests/unit/CoreBoundaryTest.php create mode 100644 tests/unit/StandardPostInterceptorTest.php diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index fb916b2..9f11387 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -2721,6 +2721,14 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' * HPCT Import tab: preview and execute import. */ private static function render_hpct_import(): void { + // HPCT is a HivePress-family plugin; the importer ships in that AddOn. + if ( ! class_exists( 'TMDO_HPCT_Import' ) ) { + echo '

' + . esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' ) + . '

'; + return; + } + // Handle import action. if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) { $result = TMDO_HPCT_Import::run(); diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index 9048b53..e227736 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -648,6 +648,11 @@ class TMDO_CLI { * @subcommand import-hpct */ public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + // HPCT is a HivePress-family plugin; the importer ships in that AddOn. + if ( ! class_exists( 'TMDO_HPCT_Import' ) ) { + WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' ); + } + if ( TMDO_HPCT_Import::is_imported() ) { WP_CLI::warning( 'HPCT settings have already been imported.' ); return; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 54988f4..f10be06 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -253,6 +253,15 @@ if ( ! function_exists( 'update_post_meta' ) ) { return true; } } +if ( ! function_exists( 'add_post_meta' ) ) { + function add_post_meta( int $post_id, string $key, $value, bool $unique = false ): int|bool { + if ( $unique && isset( $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ) ) { + return false; + } + $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value; + return 1; + } +} if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $uid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); @@ -661,7 +670,9 @@ add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); // TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon. // Tests that hit REST endpoints using listing stats receive stub responses. -if ( ! class_exists( 'TMDO_Listing_Stats' ) ) { +// The HivePress AddOn's own suite defines TMDO_TEST_SKIP_LISTING_STATS_STUB so +// it can load the real class instead of this stub. +if ( ! defined( 'TMDO_TEST_SKIP_LISTING_STATS_STUB' ) && ! class_exists( 'TMDO_Listing_Stats' ) ) { class TMDO_Listing_Stats { public static function register(): void {} public static function get_view_count( int $post_id ): int { return 0; } diff --git a/tests/unit/CoreBoundaryTest.php b/tests/unit/CoreBoundaryTest.php new file mode 100644 index 0000000..a1a3381 --- /dev/null +++ b/tests/unit/CoreBoundaryTest.php @@ -0,0 +1,152 @@ + + */ + public static function coreFileProvider(): array { + $root = dirname( __DIR__, 2 ); + $files = array(); + foreach ( array( 'includes', 'admin', 'cli', 'modules' ) as $dir ) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $root . '/' . $dir, FilesystemIterator::SKIP_DOTS ) + ); + foreach ( $iterator as $file ) { + if ( 'php' === $file->getExtension() ) { + $rel = substr( $file->getPathname(), strlen( $root ) + 1 ); + $files[ $rel ] = array( $file->getPathname() ); + } + } + } + ksort( $files ); + return $files; + } + + /** + * @dataProvider coreFileProvider + * + * @param string $path Absolute path to a core production file. + */ + public function test_addon_classes_are_only_used_behind_a_guard( string $path ): void { + $source = file_get_contents( $path ); + $this->assertIsString( $source, "Unreadable: {$path}" ); + + $lines = explode( "\n", $source ); + $offences = array(); + + foreach ( $lines as $i => $line ) { + $code = $this->strip_comments_and_strings( $line ); + foreach ( self::ADDON_CLASS_PREFIXES as $prefix ) { + // Only static calls / constant reads bind at runtime; a bare + // mention (e.g. inside an array of names to probe) does not. + if ( ! preg_match( '/\b(' . preg_quote( $prefix, '/' ) . '\w*)::/', $code, $m ) ) { + continue; + } + if ( $this->is_guarded( $lines, $i, $m[1] ) ) { + continue; + } + $offences[] = sprintf( '%s:%d — %s', basename( $path ), $i + 1, trim( $line ) ); + } + } + + $this->assertSame( + array(), + $offences, + "Core calls an AddOn class without a class_exists() guard:\n" . implode( "\n", $offences ) + ); + } + + /** + * Remove line comments and string literals so mentions inside them are ignored. + */ + private function strip_comments_and_strings( string $line ): string { + $line = preg_replace( '#(//|\*|\#).*$#', '', $line ) ?? $line; + $line = preg_replace( "/'[^']*'/", "''", $line ) ?? $line; + return preg_replace( '/"[^"]*"/', '""', $line ) ?? $line; + } + + /** + * A guard counts if class_exists() for the same class appears earlier in + * the same function (or, for top-level code, earlier in the file). + * + * Function scope rather than a fixed line window: an early-return guard at + * the top of a 60-line CLI command legitimately protects every call below + * it, and a window tight enough to be meaningful would reject that. + * + * @param string[] $lines Whole file, split on newlines. + * @param int $index Zero-based line index of the call site. + * @param string $class Class name being called. + */ + private function is_guarded( array $lines, int $index, string $class ): bool { + $from = 0; + for ( $i = $index; $i >= 0; $i-- ) { + if ( preg_match( '/^\t{1,2}(?:(?:public|private|protected|static|final|abstract)\s+)*function\s/', $lines[ $i ] ) ) { + $from = $i; + break; + } + } + $slice = implode( "\n", array_slice( $lines, $from, $index - $from + 1 ) ); + return (bool) preg_match( + '/class_exists\(\s*[\'"]' . preg_quote( $class, '/' ) . '[\'"]/', + $slice + ); + } +} diff --git a/tests/unit/StandardPostInterceptorTest.php b/tests/unit/StandardPostInterceptorTest.php new file mode 100644 index 0000000..7bea9d5 --- /dev/null +++ b/tests/unit/StandardPostInterceptorTest.php @@ -0,0 +1,219 @@ + $v ) { + $this->$k = $v; + } + } + } +} + +/** + * Concrete test double for TMDO_Standard_Post_Interceptor. + */ +class TMDO_Test_Post_Interceptor extends TMDO_Standard_Post_Interceptor { + + protected string $module = 'test_module'; + + public const FIELD_MAP = array( + 'test_meta' => 'test_col', + 'other_meta' => 'other_col', + ); + + protected function get_post_type(): string { + return 'test_post'; + } + + protected function get_table_key(): string { + return 'test_table'; + } + + protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array { + return array( + 'values' => array( + 'post_id' => $post_id, + 'status' => $post->post_status, + 'created_at' => $now, + 'updated_at' => $now, + ), + 'formats' => array( '%d', '%s', '%s', '%s' ), + ); + } +} + +/** + * Unit tests for TMDO_Standard_Post_Interceptor abstract base. + * + * Exercises the three shared hook methods (action_delete_post, + * filter_update_meta, action_insert_post) via the TMDO_Test_Post_Interceptor + * concrete subclass, which never needs to live outside this test file. + */ +class StandardPostInterceptorTest extends TestCase { + + private TMDO_Test_Post_Interceptor $interceptor; + + /** Last $wpdb->delete() call: [table, where, formats]. */ + public static array $last_delete = []; + + /** Last SQL passed to $wpdb->query(). */ + public static string $last_query = ''; + + /** Last $wpdb->insert() call: [table, data, formats]. */ + public static array $last_insert = []; + + protected function setUp(): void { + self::$last_delete = []; + self::$last_query = ''; + self::$last_insert = []; + + $this->interceptor = new TMDO_Test_Post_Interceptor(); + + // Reset Feature Flags request cache. + $ff = new ReflectionClass( TMDO_Feature_Flags::class ); + $ff->getProperty( 'cache' )->setValue( null, null ); + $GLOBALS['_wp_options'] = []; + + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public function prepare( string $sql, mixed ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', static function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + public function query( string $sql ): int|bool { + StandardPostInterceptorTest::$last_query = $sql; + return 1; + } + public function delete( string $table, array $where, array $formats ): int|false { + StandardPostInterceptorTest::$last_delete = [ $table, $where, $formats ]; + return 1; + } + public function insert( string $table, array $data, array $formats ): int|false { + StandardPostInterceptorTest::$last_insert = [ $table, $data, $formats ]; + return 1; + } + }; + } + + private function make_post( string $type = 'test_post', int $id = 1 ): WP_Post { + $post = new WP_Post( (object) [] ); + $post->ID = $id; + $post->post_type = $type; + $post->post_status = 'publish'; + $post->post_title = 'Test'; + $post->post_content = ''; + $post->post_author = 0; + $post->post_parent = 0; + return $post; + } + + // ── action_delete_post ──────────────────────────────────────────────────── + + public function test_delete_post_calls_wpdb_delete_for_correct_type(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $post = $this->make_post( 'test_post', 99 ); + $this->interceptor->action_delete_post( 99, $post ); + $this->assertStringContainsString( 'wp_test_table', self::$last_delete[0] ?? '' ); + $this->assertSame( [ 'post_id' => 99 ], self::$last_delete[1] ); + } + + public function test_delete_post_skips_wrong_post_type(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $post = $this->make_post( 'other_type', 99 ); + $this->interceptor->action_delete_post( 99, $post ); + $this->assertSame( [], self::$last_delete ); + } + + public function test_delete_post_skips_when_not_active(): void { + TMDO_Feature_Flags::set( 'test_module', 'idle' ); + $post = $this->make_post( 'test_post', 99 ); + $this->interceptor->action_delete_post( 99, $post ); + $this->assertSame( [], self::$last_delete ); + } + + // ── filter_update_meta ──────────────────────────────────────────────────── + + public function test_update_meta_issues_sql_update_for_known_key(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + // get_post_type() stub returns 'test_post' for post ID 5. + $GLOBALS['_wp_post_types'][5] = 'test_post'; + + $result = $this->interceptor->filter_update_meta( null, 5, 'test_meta', 'newval', '' ); + + $this->assertNull( $result ); + $this->assertStringContainsString( 'UPDATE', self::$last_query ); + $this->assertStringContainsString( 'test_col', self::$last_query ); + $this->assertStringContainsString( 'newval', self::$last_query ); + } + + public function test_update_meta_skips_unregistered_key(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $GLOBALS['_wp_post_types'][5] = 'test_post'; + + $this->interceptor->filter_update_meta( null, 5, 'unknown_key', 'val', '' ); + + $this->assertSame( '', self::$last_query ); + } + + public function test_update_meta_skips_wrong_post_type(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $GLOBALS['_wp_post_types'][5] = 'other_type'; + + $this->interceptor->filter_update_meta( null, 5, 'test_meta', 'val', '' ); + + $this->assertSame( '', self::$last_query ); + } + + public function test_update_meta_passes_through_check_unchanged(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $GLOBALS['_wp_post_types'][5] = 'test_post'; + $sentinel = 'original_check'; + + $result = $this->interceptor->filter_update_meta( $sentinel, 5, 'test_meta', 'v', '' ); + + $this->assertSame( $sentinel, $result ); + } + + // ── action_insert_post ──────────────────────────────────────────────────── + + public function test_insert_post_calls_wpdb_insert_for_new_post(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $post = $this->make_post( 'test_post', 42 ); + $this->interceptor->action_insert_post( 42, $post, false ); + $this->assertStringContainsString( 'wp_test_table', self::$last_insert[0] ?? '' ); + $this->assertSame( 42, self::$last_insert[1]['post_id'] ?? 0 ); + } + + public function test_insert_post_skips_updates(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $post = $this->make_post( 'test_post', 42 ); + $this->interceptor->action_insert_post( 42, $post, true ); + $this->assertSame( [], self::$last_insert ); + } + + public function test_insert_post_skips_wrong_post_type(): void { + TMDO_Feature_Flags::set( 'test_module', 'complete' ); + $post = $this->make_post( 'other_type', 42 ); + $this->interceptor->action_insert_post( 42, $post, false ); + $this->assertSame( [], self::$last_insert ); + } +}