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 );
+ }
+}