diff --git a/admin/assets/wpdo-settings.js b/admin/assets/wpdo-settings.js index 8cf7cc2..6389e71 100644 --- a/admin/assets/wpdo-settings.js +++ b/admin/assets/wpdo-settings.js @@ -92,7 +92,7 @@ if ( data.success ) { showStatus( statusEl, i18n.saved, false ); } else { - showStatus( statusEl, i18n.error, true ); + showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true ); } } ) .catch( () => { diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index 27827ac..eeca602 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -398,17 +398,7 @@ class TMDO_Admin { // v2.5.4: Entity Bridge settings (v2.11.0: post added). update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false ); - if ( class_exists( 'TMDO_Mode_Manager' ) ) { - foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) { - $new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) - ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) ) - : ''; - if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) { - TMDO_Mode_Manager::set( $entity_type, $new_mode ); - } - } - TMDO_Mode_Manager::reset_cache(); - } + self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) ); if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { TMDO_Hook_Bus_Bridge::reset_cache(); } @@ -3144,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '

' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '

'; } + foreach ( self::pull_bridge_mode_refusals() as $refused ) { + echo '

' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '

'; + } $enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled(); $email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : ''; $throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24; @@ -3743,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' } } - private static function save_section_entity_bridge(): void { + /** + * Save the Entity Bridge section (Hook Bus switch and bridge modes). + * + * @return string[] Refused changes, ":". + */ + private static function save_section_entity_bridge(): array { update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false ); - if ( class_exists( 'TMDO_Mode_Manager' ) ) { - foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) { - $new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) - ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) ) - : ''; - if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) { - TMDO_Mode_Manager::set( $entity_type, $new_mode ); - } - } - TMDO_Mode_Manager::reset_cache(); - } + $refused = self::apply_bridge_modes( self::posted_bridge_modes() ); if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { TMDO_Hook_Bus_Bridge::reset_cache(); } + return $refused; + } + + /** + * Bridge modes posted by the settings form, keyed by entity type ('' when absent). + * + * @return array + */ + private static function posted_bridge_modes(): array { + $posted = array(); + foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) { + $posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) + ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) ) + : ''; + } + return $posted; } private static function save_section_hp_transient(): void { @@ -3786,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' } // phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle + /** + * Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or + * unknown modes are skipped; refused changes (unsafe jump, Promotion Guard) + * are returned instead of being dropped silently (2.4.2 PR-135). + * + * @param array $posted Mode per entity type. + * @return string[] Refused changes, ":". + */ + public static function apply_bridge_modes( array $posted ): array { + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return array(); + } + $refused = array(); + foreach ( $posted as $entity_type => $new_mode ) { + if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) { + continue; + } + $result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode ); + if ( is_wp_error( $result ) ) { + $refused[] = $entity_type . ':' . $result->get_error_message(); + } + } + TMDO_Mode_Manager::reset_cache(); + return $refused; + } + + /** + * Keep refused bridge-mode changes for the page the settings form redirects to. + * + * @param string[] $refused Refused changes. + * @return void + */ + public static function remember_bridge_mode_refusals( array $refused ): void { + if ( $refused ) { + set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS ); + } + } + + /** + * Refused bridge-mode changes remembered for this user, once. + * + * @return string[] + */ + public static function pull_bridge_mode_refusals(): array { + $key = 'wpdo_mode_refused_' . get_current_user_id(); + $refused = get_transient( $key ); + delete_transient( $key ); + return is_array( $refused ) ? $refused : array(); + } + /** * AJAX: save a single settings section without a full page reload. */ @@ -3807,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' if ( ! isset( $handlers[ $section ] ) ) { wp_send_json_error( 'invalid_section', 400 ); } - self::{$handlers[ $section ]}(); + $refused = self::{$handlers[ $section ]}(); + if ( is_array( $refused ) && $refused ) { + wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) ); + } wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) ); } } diff --git a/tests/unit/AdminBridgeModeSaveTest.php b/tests/unit/AdminBridgeModeSaveTest.php new file mode 100644 index 0000000..ef7b8a4 --- /dev/null +++ b/tests/unit/AdminBridgeModeSaveTest.php @@ -0,0 +1,78 @@ + true ); + $GLOBALS['_wp_current_user_id'] = 7; + 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_an_accepted_change_is_applied_without_refusals(): void { + $this->modes( array( 'user' => 'disabled' ) ); + + $refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) ); + + $this->assertSame( array(), $refused ); + $this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) ); + } + + public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void { + $this->modes( array( 'post' => 'disabled' ) ); + + $refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) ); + + $this->assertCount( 1, $refused ); + $this->assertStringStartsWith( 'post', $refused[0] ); + $this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) ); + } + + public function test_empty_or_unknown_modes_are_ignored(): void { + $this->modes( array( 'term' => 'disabled' ) ); + + $this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) ); + $this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_a_promotion_refused_by_the_guard_is_reported(): void { + eval( 'namespace HivePress; class Core {}' ); + $this->modes( array( 'user' => 'shadow_read' ) ); + + $refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) ); + + $this->assertCount( 1, $refused ); + $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) ); + } + + /** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */ + public function test_refusals_are_kept_for_the_next_page_view_only(): void { + TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) ); + + $this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() ); + $this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() ); + } +}