diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index d6d1a18..a7fcf5d 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; // ── v2.0.0 Engine + Adapters + Modules ─────────────────────────────────── require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; @@ -292,6 +293,8 @@ if ( is_admin() ) { TMDO_Deprecated_Addons_Notice::register(); require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php'; TMDO_Missing_Addons_Notice::register(); + require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php'; + TMDO_Promotion_Guard_Notice::register(); } // ── WP-CLI ───────────────────────────────────────────────────────────────── diff --git a/admin/class-tmdo-promotion-guard-notice.php b/admin/class-tmdo-promotion-guard-notice.php new file mode 100644 index 0000000..c5e6cb9 --- /dev/null +++ b/admin/class-tmdo-promotion-guard-notice.php @@ -0,0 +1,63 @@ + $integrations ) { + $lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type ); + } + printf( + '

%s %s

', + esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ), + esc_html( + sprintf( + /* translators: %s: "entity (integrations): command" list */ + __( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ), + implode( ';', $lines ) + ) + ) + ); + } +} diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php index 5fed59d..fa79a90 100644 --- a/includes/engine/class-tmdo-mode-manager.php +++ b/includes/engine/class-tmdo-mode-manager.php @@ -140,6 +140,11 @@ final class TMDO_Mode_Manager { ); } + $guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode ); + if ( $guarded ) { + return $guarded; + } + $all = self::all(); $all[ $entity_type ] = $new_mode; diff --git a/includes/engine/class-tmdo-promotion-guard.php b/includes/engine/class-tmdo-promotion-guard.php new file mode 100644 index 0000000..4af8dac --- /dev/null +++ b/includes/engine/class-tmdo-promotion-guard.php @@ -0,0 +1,140 @@ + array( 'post', 'user', 'term', 'comment' ), + 'WooCommerce' => array( 'post', 'user' ), + ); + + /** + * Check a mode change. Only promotion to aeav_only is guarded. + * + * @param string $entity_type Entity type. + * @param string $new_mode Target mode. + * @return WP_Error|null Error when refused. + */ + public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error { + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) { + return null; + } + return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY ); + } + + /** + * Check a cleanup of the entity's native managed meta rows. + * + * @param string $entity_type Entity type. + * @return WP_Error|null Error when refused. + */ + public static function check_cleanup( string $entity_type ): ?WP_Error { + return self::refuse( $entity_type, 'cleanup' ); + } + + /** + * Guarded entity types that are already in aeav_only (promoted before the + * guard existed). Native meta may be stale there. + * + * @return array Entity type → integration names. + */ + public static function violations(): array { + $out = array(); + foreach ( TMDO_Mode_Manager::all() as $type => $mode ) { + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { + continue; + } + $integrations = self::integrations_for( $type ); + if ( ! empty( $integrations ) ) { + $out[ $type ] = $integrations; + } + } + return $out; + } + + /** + * Active integrations that read this entity type natively. + * + * @param string $entity_type Entity type. + * @return string[] + */ + private static function integrations_for( string $entity_type ): array { + $active = array( + 'HivePress' => TMDO_Compatibility::is_hivepress_active(), + 'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(), + ); + + $names = array(); + foreach ( self::GUARDED as $name => $types ) { + if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) { + $names[] = $name; + } + } + return $names; + } + + /** + * Build the refusal, unless no integration applies or a filter allows it. + * + * @param string $entity_type Entity type. + * @param string $action 'aeav_only' or 'cleanup'. + * @return WP_Error|null + */ + private static function refuse( string $entity_type, string $action ): ?WP_Error { + $integrations = self::integrations_for( $entity_type ); + if ( empty( $integrations ) ) { + return null; + } + + /** + * Allow a guarded aeav_only promotion or cleanup anyway. + * + * @param bool $allow Default false. + * @param string $entity_type Entity type. + * @param string $action 'aeav_only' or 'cleanup'. + * @param string[] $integrations Integrations that read this entity natively. + */ + if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) { + return null; + } + + return new WP_Error( + 'promotion_guarded', + sprintf( + /* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */ + __( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ), + implode( ' / ', $integrations ), + $entity_type, + $action + ) + ); + } +} diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php index 67b54f8..26caf95 100644 --- a/includes/migration/class-tmdo-post-migration.php +++ b/includes/migration/class-tmdo-post-migration.php @@ -228,6 +228,10 @@ final class TMDO_Post_Migration { 'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only' ); } + $guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE ); + if ( $guarded ) { + throw new RuntimeException( esc_html( $guarded->get_error_message() ) ); + } global $wpdb; $keys = self::get_managed_keys(); diff --git a/includes/migration/phases/class-tmdo-phase-cleanup.php b/includes/migration/phases/class-tmdo-phase-cleanup.php index 28b59e1..2c2a0a6 100644 --- a/includes/migration/phases/class-tmdo-phase-cleanup.php +++ b/includes/migration/phases/class-tmdo-phase-cleanup.php @@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" ); } + $guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type ); + if ( $guarded ) { + throw new \RuntimeException( $guarded->get_error_message() ); + } if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) { throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 54161d5..2ab62a7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -613,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; // Engine (v2.0.0). require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index c19554b..9578e92 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -576,6 +576,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php'; require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; diff --git a/tests/unit/PromotionGuardTest.php b/tests/unit/PromotionGuardTest.php new file mode 100644 index 0000000..7cf830e --- /dev/null +++ b/tests/unit/PromotionGuardTest.php @@ -0,0 +1,163 @@ + true ); + TMDO_Mode_Manager::reset_cache(); + } + + private function modes( array $modes ): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes; + TMDO_Mode_Manager::reset_cache(); + } + + public function test_without_integrations_aeav_only_is_allowed(): void { + $this->modes( array( 'post' => 'shadow_read' ) ); + + $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'aeav_only' ) ); + $this->assertNull( TMDO_Promotion_Guard::check_cleanup( 'post' ) ); + $this->assertSame( array(), TMDO_Promotion_Guard::violations() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_hivepress_refuses_aeav_only_for_every_entity(): void { + eval( 'namespace HivePress; class Core {}' ); + + foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) { + $this->modes( array( $type => 'shadow_read' ) ); + + $result = TMDO_Mode_Manager::set( $type, 'aeav_only' ); + + $this->assertInstanceOf( WP_Error::class, $result, $type ); + $this->assertSame( 'promotion_guarded', $result->get_error_code(), $type ); + $this->assertStringContainsString( 'HivePress', $result->get_error_message(), $type ); + $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( $type ), "{$type} mode must not change" ); + $this->assertSame( array( $type => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'], "{$type} option must not change" ); + } + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_woocommerce_guards_post_and_user_only(): void { + eval( 'class WooCommerce {}' ); + + $this->modes( array( 'post' => 'shadow_read', 'user' => 'shadow_read', 'term' => 'shadow_read' ) ); + + $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) ); + $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'user', 'aeav_only' ) ); + $this->assertTrue( TMDO_Mode_Manager::set( 'term', 'aeav_only' ) ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_guard_allows_lower_modes_and_demotion(): void { + eval( 'namespace HivePress; class Core {}' ); + + $this->modes( array( 'post' => 'disabled' ) ); + $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ) ); + $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'shadow_read' ) ); + + $this->modes( array( 'post' => 'aeav_only' ) ); + $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ), 'the way out of aeav_only stays open' ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_filter_can_allow_a_guarded_promotion(): void { + eval( 'namespace HivePress; class Core {}' ); + add_filter( + 'tmdo_promotion_guard_allow', + static function ( $allow, $entity_type, $action, $integrations ) { + return 'user' === $entity_type && 'aeav_only' === $action && array( 'HivePress' ) === $integrations; + }, + 10, + 4 + ); + + $this->modes( array( 'user' => 'shadow_read', 'post' => 'shadow_read' ) ); + + $this->assertTrue( TMDO_Mode_Manager::set( 'user', 'aeav_only' ) ); + $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_cleanup_is_refused_even_in_aeav_only(): void { + eval( 'namespace HivePress; class Core {}' ); + $this->modes( array( 'post' => 'aeav_only', 'user' => 'aeav_only' ) ); + + $error = TMDO_Promotion_Guard::check_cleanup( 'post' ); + $this->assertInstanceOf( WP_Error::class, $error ); + + try { + TMDO_Post_Migration::cleanup(); + $this->fail( 'post cleanup must be refused' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( 'HivePress', $e->getMessage() ); + } + + $job = array( 'log' => array(), 'options' => array() ); + try { + ( new TMDO_Phase_Cleanup( 'user' ) )->execute( $job ); + $this->fail( 'user cleanup phase must be refused' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( 'HivePress', $e->getMessage() ); + } + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_sites_already_in_aeav_only_are_reported(): void { + eval( 'class WooCommerce {}' ); + $this->modes( array( 'post' => 'aeav_only', 'term' => 'aeav_only' ) ); + + $this->assertSame( array( 'post' => array( 'WooCommerce' ) ), TMDO_Promotion_Guard::violations() ); + + ob_start(); + TMDO_Promotion_Guard_Notice::render(); + $html = (string) ob_get_clean(); + $this->assertStringContainsString( 'post', $html ); + $this->assertStringContainsString( 'bridge-restore-native post', $html ); + } + + public function test_notice_is_silent_without_violations(): void { + $this->modes( array( 'post' => 'aeav_only' ) ); + + ob_start(); + TMDO_Promotion_Guard_Notice::render(); + $this->assertSame( '', ob_get_clean() ); + } +}