diff --git a/includes/Admin/Activation.php b/includes/Admin/Activation.php new file mode 100644 index 00000000..a2cd9b9d --- /dev/null +++ b/includes/Admin/Activation.php @@ -0,0 +1,33 @@ +[] + */ + private const UPGRADE_CLASSES = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as a single const. + V0_5_0::class, + V0_6_0::class, + ); + + /** + * Initialize the class. + * + * @since x.x.x + */ + public function init(): void { + // Runs as a fallback, in case the activation hook is missed. + add_action( 'admin_init', array( $this, 'do_upgrades' ) ); + + add_action( 'admin_notices', array( $this, 'failed_upgrade_notice' ) ); + } + + /** + * Checks for and runs any pending upgrades. + * + * @since x.x.x + */ + public static function do_upgrades(): void { + $db_version = get_option( self::VERSION_OPTION_KEY, '' ); + + foreach ( self::UPGRADE_CLASSES as $upgrade_class ) { + /** + * Skip upgrades for newer versions. + * @todo Remove the !empty() check once we no long need to support < v0.5.0 and '' means a new install. + */ + if ( ! empty( $db_version ) && version_compare( $db_version, $upgrade_class::$version, '>=' ) ) { + continue; + } + + $upgrade = new $upgrade_class( $db_version ); + $result = $upgrade->run(); + + // Store the error message and stop if the upgrade failed. + if ( is_wp_error( $result ) ) { + update_option( + self::FAILED_UPGRADE_OPTION_KEY, + array( + 'version' => $upgrade_class::$version, + 'error' => $result->get_error_message(), + ) + ); + return; + } + + $db_version = $upgrade_class::$version; + } + + // If all upgrades completed successfully, the plugin was successfully upgraded to the latest version. + delete_option( self::FAILED_UPGRADE_OPTION_KEY ); + update_option( self::VERSION_OPTION_KEY, WPAI_VERSION ); + } + + /** + * Displays an admin notice if a plugin upgrade failed, with the error message. + * + * @since x.x.x + */ + public function failed_upgrade_notice(): void { + // Skip if there's no failures. + $failed_upgrade = get_option( self::FAILED_UPGRADE_OPTION_KEY, false ); + if ( ! $failed_upgrade ) { + return; + } + + // If the error is set but empty, clean it up. + if ( empty( $failed_upgrade['version'] ) || empty( $failed_upgrade['error'] ) ) { + delete_option( self::FAILED_UPGRADE_OPTION_KEY ); + return; + } + + // Display the error message. + wp_admin_notice( + sprintf( + /* translators: 1. The version the upgrade failed on, 2. The error message. */ + esc_html__( 'WordPress AI failed to upgrade to %1$s. Migration version %2$s failed with the following error: %3$s. Please deactivate and reactivate the plugin to try again.', 'ai' ), + WPAI_VERSION, + esc_html( $failed_upgrade['version'] ), + esc_html( $failed_upgrade['error'] ) + ), + array( + 'type' => 'error', + 'dismissible' => false, + ) + ); + } +} diff --git a/includes/Admin/Upgrades/Abstract_Upgrade.php b/includes/Admin/Upgrades/Abstract_Upgrade.php new file mode 100644 index 00000000..a92519f8 --- /dev/null +++ b/includes/Admin/Upgrades/Abstract_Upgrade.php @@ -0,0 +1,97 @@ +' ) ) { + throw new \InvalidArgumentException( 'Invalid database version provided for upgrade.' ); + } + + $this->db_version = $db_version; + } + + /** + * Performs the upgrade routine. + * + * @since x.x.x + * + * @return true|\WP_Error True on success, or a WP_Error on failure. + */ + public function run() { + if ( version_compare( $this->db_version, static::$version, '>=' ) ) { + // No upgrade needed. + return true; + } + + try { + $this->upgrade(); + } catch ( \Throwable $e ) { + return new \WP_Error( 'wpai_upgrade_failed', $e->getMessage() ); + } + + return true; + } + + /** + * The upgrade process. + * + * @throws \Exception Throws an exception if the upgrade fails. + */ + abstract protected function upgrade(): void; +} diff --git a/includes/Admin/Upgrades/V0_5_0.php b/includes/Admin/Upgrades/V0_5_0.php new file mode 100644 index 00000000..2d8a8bd7 --- /dev/null +++ b/includes/Admin/Upgrades/V0_5_0.php @@ -0,0 +1,81 @@ + 'connectors_ai_openai_api_key', + 'google' => 'connectors_ai_google_api_key', + 'anthropic' => 'connectors_ai_anthropic_api_key', + ); + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public static string $version = '0.5.0'; + + /** + * {@inheritDoc} + * + * Copies legacy provider credentials to the new per-provider options. + * + * Reads the old combined credentials option and, for each known provider, + * copies the credential to the new option only when the new option is empty. + * + * @since x.x.x + */ + protected function upgrade(): void { + $old_credentials = get_option( self::OLD_OPTION, array() ); + + if ( empty( $old_credentials ) || ! is_array( $old_credentials ) ) { + return; + } + + foreach ( self::PROVIDER_MAP as $provider => $new_option ) { + if ( empty( $old_credentials[ $provider ] ) ) { + continue; + } + + // Only migrate if the new option slot is empty. + if ( '' !== get_option( $new_option, '' ) ) { + continue; + } + + update_option( $new_option, $old_credentials[ $provider ] ); + } + + delete_option( self::OLD_OPTION ); + } +} diff --git a/includes/Admin/Upgrades/V0_6_0.php b/includes/Admin/Upgrades/V0_6_0.php new file mode 100644 index 00000000..ed7976a7 --- /dev/null +++ b/includes/Admin/Upgrades/V0_6_0.php @@ -0,0 +1,79 @@ +migrate_option( 'ai_experiment_enabled', 'wpai_feature_enabled' ); + + // Loop through the features and migrate them. + // We don't use the classes to protect for future compatibility. + $features = array( + 'abilities-explorer', + 'alt-text-generation', + 'example-experiment', + 'excerpt-generation', + 'image-generation', + 'review-notes', + 'summarization', + 'title-generation', + ); + + foreach ( $features as $feature ) { + $this->migrate_option( "ai_experiment_{$feature}_enabled", "wpai_feature_{$feature}_enabled" ); + } + } + + /** + * Migrates an individual option from the old name. + * + * Will skip migration if the new option already has a value. + * + * @param string $old_option The old option name. + * @param string $new_option The new option name. + */ + private function migrate_option( string $old_option, string $new_option ): void { + $old_value = get_option( $old_option, '' ); + if ( '' === $old_value || '' !== get_option( $new_option, '' ) ) { + return; + } + + update_option( $new_option, $old_value ); + delete_option( $old_option ); + } +} diff --git a/includes/Migrations/Credential_Migration.php b/includes/Migrations/Credential_Migration.php deleted file mode 100644 index b1753096..00000000 --- a/includes/Migrations/Credential_Migration.php +++ /dev/null @@ -1,111 +0,0 @@ - - */ - private static function get_provider_map(): array { - return array( - 'openai' => 'connectors_ai_openai_api_key', - 'google' => 'connectors_ai_google_api_key', - 'anthropic' => 'connectors_ai_anthropic_api_key', - ); - } - - /** - * Runs the migration if the stored version is below the target version. - * - * Compares the stored version option against the target version and, when - * an upgrade is detected, migrates credentials then records the new version. - * - * @since 0.5.0 - */ - public function run(): void { - $stored_version = get_option( self::VERSION_OPTION, '0.0.0' ); - - if ( version_compare( (string) $stored_version, self::TARGET_VERSION, '>=' ) ) { - return; - } - - $this->maybe_migrate_credentials(); - update_option( self::VERSION_OPTION, WPAI_VERSION ); - } - - /** - * Copies legacy provider credentials to the new per-provider options. - * - * Reads the old combined credentials option and, for each known provider, - * copies the credential to the new option only when the new option is empty. - * - * @since 0.5.0 - */ - private function maybe_migrate_credentials(): void { - $old_credentials = get_option( self::OLD_OPTION, array() ); - - if ( empty( $old_credentials ) || ! is_array( $old_credentials ) ) { - return; - } - - foreach ( self::get_provider_map() as $provider => $new_option ) { - if ( empty( $old_credentials[ $provider ] ) ) { - continue; - } - - // Only migrate if the new option slot is empty. - if ( '' !== get_option( $new_option, '' ) ) { - continue; - } - - update_option( $new_option, $old_credentials[ $provider ] ); - } - - delete_option( self::OLD_OPTION ); - } -} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index fc5ff406..9ac568e9 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,10 +12,11 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; +use WordPress\AI\Admin\Activation; +use WordPress\AI\Admin\Upgrades; use WordPress\AI\Experiments\Experiments; use WordPress\AI\Features\Loader; use WordPress\AI\Features\Registry; -use WordPress\AI\Migrations\Credential_Migration; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; @@ -173,8 +174,8 @@ function load(): void { require_once WPAI_PLUGIN_DIR . 'includes/autoload.php'; require_once WPAI_PLUGIN_DIR . 'includes/helpers.php'; - // Run any pending migrations. - ( new Credential_Migration() )->run(); + // Handle any pending upgrades. + ( new Upgrades() )->init(); // Handle deprecated code. ( new Deprecated() )->init(); @@ -249,3 +250,20 @@ static function () { } add_action( 'plugins_loaded', __NAMESPACE__ . '\load' ); + + +/** + * Triggers when the plugin is activated. + * + * @since x.x.x + */ +register_activation_hook( + WPAI_PLUGIN_FILE, + static function (): void { + // Load required files. + require_once WPAI_PLUGIN_DIR . 'includes/autoload.php'; + require_once WPAI_PLUGIN_DIR . 'includes/helpers.php'; + + Activation::activation_callback(); + } +); diff --git a/tests/Integration/Includes/Admin/ActivationTest.php b/tests/Integration/Includes/Admin/ActivationTest.php new file mode 100644 index 00000000..75f4a333 --- /dev/null +++ b/tests/Integration/Includes/Admin/ActivationTest.php @@ -0,0 +1,53 @@ +assertEquals( WPAI_VERSION, get_option( 'wpai_version' ) ); + } +} diff --git a/tests/Integration/Includes/Migrations/Credential_MigrationTest.php b/tests/Integration/Includes/Admin/Upgrades/V0_5_0Test.php similarity index 76% rename from tests/Integration/Includes/Migrations/Credential_MigrationTest.php rename to tests/Integration/Includes/Admin/Upgrades/V0_5_0Test.php index e87b946d..c9be0402 100644 --- a/tests/Integration/Includes/Migrations/Credential_MigrationTest.php +++ b/tests/Integration/Includes/Admin/Upgrades/V0_5_0Test.php @@ -1,21 +1,21 @@ run(); + ( new V0_5_0( '' ) )->run(); $this->assertEquals( 'sk-openai-key', get_option( 'connectors_ai_openai_api_key' ) ); $this->assertEquals( 'google-key', get_option( 'connectors_ai_google_api_key' ) ); @@ -101,10 +101,10 @@ public function test_run_migrates_credentials() { * * @since 0.5.0 */ - public function test_run_stores_version_after_migration() { - ( new Credential_Migration() )->run(); + public function test_run_returns_success_after_migration() { + $success = ( new V0_5_0( '' ) )->run(); - $this->assertEquals( '0.5.0', get_option( 'ai_experiments_version' ) ); + $this->assertTrue( $success ); } /** @@ -113,13 +113,12 @@ public function test_run_stores_version_after_migration() { * @since 0.5.0 */ public function test_run_skips_when_version_already_current() { - update_option( 'ai_experiments_version', '0.5.0' ); update_option( 'wp_ai_client_provider_credentials', array( 'openai' => 'sk-old-key' ) ); - ( new Credential_Migration() )->run(); + ( new V0_5_0( '0.5.0' ) )->run(); $this->assertNull( $this->get_option_from_db( 'connectors_ai_openai_api_key' ), @@ -133,7 +132,7 @@ public function test_run_skips_when_version_already_current() { * @since 0.5.0 */ public function test_run_does_nothing_on_fresh_install() { - ( new Credential_Migration() )->run(); + ( new V0_5_0( '' ) )->run(); foreach ( self::get_connector_options() as $option ) { $this->assertNull( @@ -155,7 +154,7 @@ public function test_run_does_not_overwrite_existing_new_credentials() { array( 'openai' => 'sk-old-key' ) ); - ( new Credential_Migration() )->run(); + ( new V0_5_0( '' ) )->run(); $this->assertEquals( 'sk-already-set', @@ -179,7 +178,7 @@ public function test_run_migrates_only_providers_missing_new_credentials() { ) ); - ( new Credential_Migration() )->run(); + ( new V0_5_0( '' ) )->run(); $this->assertEquals( 'sk-already-set', @@ -193,35 +192,6 @@ public function test_run_migrates_only_providers_missing_new_credentials() { ); } - /** - * Tests that a second call to run() after migration is already complete is a no-op. - * - * @since 0.5.0 - */ - public function test_run_is_idempotent() { - update_option( - 'wp_ai_client_provider_credentials', - array( 'openai' => 'sk-openai-key' ) - ); - - $migration = new Credential_Migration(); - $migration->run(); - - // Simulate the old option being changed after migration has already run. - update_option( - 'wp_ai_client_provider_credentials', - array( 'openai' => 'sk-different-key' ) - ); - - $migration->run(); - - $this->assertEquals( - 'sk-openai-key', - get_option( 'connectors_ai_openai_api_key' ), - 'Second run should not re-migrate' - ); - } - /** * Returns the raw option value directly from the database, bypassing all filters. * diff --git a/tests/Integration/Includes/Admin/Upgrades/V0_6_0Test.php b/tests/Integration/Includes/Admin/Upgrades/V0_6_0Test.php new file mode 100644 index 00000000..dd2a1d61 --- /dev/null +++ b/tests/Integration/Includes/Admin/Upgrades/V0_6_0Test.php @@ -0,0 +1,183 @@ +run(); + + $this->assertEquals( '1', get_option( 'wpai_feature_enabled' ) ); + $this->assertNull( + $this->get_option_from_db( 'ai_experiment_enabled' ), + 'Old option should be deleted' + ); + } + + /** + * Tests that run() returns true on success. + * + * @since x.x.x + */ + public function test_run_returns_success_after_migration() { + $result = ( new V0_6_0( '' ) )->run(); + + $this->assertTrue( $result ); + } + + /** + * Tests that run() skips when version is already at target. + * + * @since x.x.x + */ + public function test_run_skips_when_version_already_current() { + update_option( 'ai_experiment_enabled', '1' ); + + ( new V0_6_0( '0.6.0' ) )->run(); + + $this->assertNull( + $this->get_option_from_db( 'wpai_feature_enabled' ), + 'Should not write new option when version is current' + ); + $this->assertEquals( + '1', + get_option( 'ai_experiment_enabled' ), + 'Old option should remain when skipped' + ); + } + + /** + * Tests that run() does nothing on fresh install (no old options). + * + * @since x.x.x + */ + public function test_run_does_nothing_on_fresh_install() { + ( new V0_6_0( '' ) )->run(); + + $this->assertNull( + $this->get_option_from_db( 'wpai_feature_enabled' ), + 'wpai_feature_enabled should not be written on fresh install' + ); + } + + /** + * Tests that run() migrates only options where new option is empty. + * + * @since x.x.x + */ + public function test_run_migrates_only_options_missing_new_value() { + update_option( 'wpai_feature_enabled', 'already-set' ); + update_option( 'ai_experiment_enabled', '1' ); + update_option( 'ai_experiment_excerpt-generation_enabled', '1' ); + + ( new V0_6_0( '' ) )->run(); + + $this->assertEquals( + 'already-set', + get_option( 'wpai_feature_enabled' ), + 'Global feature flag should not be overwritten' + ); + $this->assertEquals( + '1', + get_option( 'wpai_feature_excerpt-generation_enabled' ), + 'Excerpt generation should be migrated' + ); + $this->assertNull( + $this->get_option_from_db( 'ai_experiment_excerpt-generation_enabled' ), + 'Migrated old option should be deleted' + ); + $this->assertEquals( + '1', + get_option( 'ai_experiment_enabled' ), + 'Non-migrated old option should remain' + ); + } + + /** + * Tests that run() handles empty string old values correctly. + * + * @since x.x.x + */ + public function test_run_skips_empty_old_values() { + update_option( 'ai_experiment_enabled', '' ); + + ( new V0_6_0( '' ) )->run(); + + $this->assertNull( + $this->get_option_from_db( 'wpai_feature_enabled' ), + 'New option should not be set when old is empty string' + ); + } + + /** + * Returns the raw option value directly from the database, bypassing all filters. + * + * Returns null if the option row does not exist. + * + * @since x.x.x + * + * @param string $option_name The option name to look up. + * @return string|null The raw value, or null if the row is absent. + */ + private function get_option_from_db( string $option_name ): ?string { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", + $option_name + ) + ); + } +} diff --git a/tests/Integration/Includes/Admin/UpgradesTest.php b/tests/Integration/Includes/Admin/UpgradesTest.php new file mode 100644 index 00000000..b4caf36f --- /dev/null +++ b/tests/Integration/Includes/Admin/UpgradesTest.php @@ -0,0 +1,179 @@ +assertEquals( WPAI_VERSION, get_option( 'wpai_version' ) ); + } + + /** + * Tests that do_upgrades() skips all upgrades when already at latest version. + * + * @since x.x.x + */ + public function test_do_upgrades_skips_when_version_is_current() { + update_option( 'wpai_version', '99.0.0' ); + // This option is from v0.5.0. If that gets removed we should use a different dummy op + update_option( 'ai_experiment_enabled', '1' ); + + Upgrades::do_upgrades(); + + $this->assertEquals( + '1', + get_option( 'ai_experiment_enabled' ), + 'Old option should remain when skipped' + ); + $this->assertNull( + get_option( 'ai_features_enabled', null ), + 'New option should not be set when skipped' + ); + } + + /** + * Tests that do_upgrades() clears failed upgrade message on success. + * + * @since x.x.x + */ + public function test_do_upgrades_clears_failed_message_on_success() { + update_option( + 'wpai_failed_upgrade_message', + array( + 'version' => '0.5.0', + 'error' => 'Previous failure', + ) + ); + + Upgrades::do_upgrades(); + + $this->assertFalse( + get_option( 'wpai_failed_upgrade_message', false ), + 'Failed upgrade message should be cleared' + ); + } + + /** + * Tests that init() doesn't throw errors and registers hooks. + * + * @since x.x.x + */ + public function test_init_registers_admin_init_hook() { + $upgrades = new Upgrades(); + $upgrades->init(); + + $this->assertTrue( has_action( 'admin_init', array( $upgrades, 'do_upgrades' ) ) !== false ); + $this->assertTrue( has_action( 'admin_notices', array( $upgrades, 'failed_upgrade_notice' ) ) !== false ); + } + + /** + * Tests that failed_upgrade_notice() outputs nothing when no failure. + * + * @since x.x.x + */ + public function test_failed_upgrade_notice_outputs_nothing_when_no_failure() { + $upgrades = new Upgrades(); + + ob_start(); + $upgrades->failed_upgrade_notice(); + $output = ob_get_clean(); + + $this->assertEmpty( $output ); + } + + /** + * Tests that failed_upgrade_notice() clears invalid failure data. + * + * @since x.x.x + */ + public function test_failed_upgrade_notice_clears_invalid_data() { + update_option( + 'wpai_failed_upgrade_message', + array( + 'version' => '', + 'error' => '', + ) + ); + + $upgrades = new Upgrades(); + $upgrades->failed_upgrade_notice(); + + $this->assertFalse( + get_option( 'wpai_failed_upgrade_message', false ), + 'Invalid failure data should be cleared' + ); + } + + /** + * Tests that failed_upgrade_notice() outputs error message. + * + * @since x.x.x + */ + public function test_failed_upgrade_notice_outputs_error_message() { + update_option( + 'wpai_failed_upgrade_message', + array( + 'version' => WPAI_VERSION, + 'error' => 'Test upgrade failure', + ) + ); + + $upgrades = new Upgrades(); + + ob_start(); + $upgrades->failed_upgrade_notice(); + $output = ob_get_clean(); + + $this->assertStringContainsString( WPAI_VERSION, $output ); + $this->assertStringContainsString( 'Test upgrade failure', $output ); + } +}