From 91838c30fc42207ad2cea3997f4c3ae5934d0e90 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:35:32 -0400 Subject: [PATCH 1/2] fix(upgrade): run Activator on version mismatch via plugins_loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress only fires register_activation_hook on the one-time activation event — not on auto-update or on upload-overwrite upgrades. That means existing installs that receive new plugin code (e.g. new tables, new permission rows, new cron events) never get the schema seeded. Add Activator::maybe_upgrade() that compares get_option('escalated_version') against ESCALATED_VERSION and re-runs activate() on mismatch. activate() is already idempotent (dbDelta, upsert loops, exists-guards), so re-running on every version bump is safe. Wire it into the plugins_loaded hook so it fires ahead of boot(). --- escalated.php | 1 + includes/class-activator.php | 17 ++++++++++++ tests/Test_Activator.php | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/escalated.php b/escalated.php index daef0de..395df0c 100644 --- a/escalated.php +++ b/escalated.php @@ -62,5 +62,6 @@ static function (string $class): void { register_deactivation_hook(__FILE__, [\Escalated\Deactivator::class, 'deactivate']); add_action('plugins_loaded', function () { + \Escalated\Activator::maybe_upgrade(); \Escalated\Escalated::instance()->boot(); }); diff --git a/includes/class-activator.php b/includes/class-activator.php index c7ffa64..44046ed 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -23,6 +23,23 @@ public static function activate(): void flush_rewrite_rules(); } + /** + * Re-run `activate()` when the stored plugin version differs from the current one. + * + * WordPress does not fire activation hooks on auto-update or on manual + * upload-overwrite upgrades, so existing installs can end up on new code + * without the schema/permission seed having run. Every step inside + * `activate()` is idempotent (dbDelta, upsert loops, existence guards), + * so re-running on version change is safe. + */ + public static function maybe_upgrade(): void + { + if (get_option('escalated_version') === ESCALATED_VERSION) { + return; + } + self::activate(); + } + /** * Create all 21 database tables using dbDelta. */ diff --git a/tests/Test_Activator.php b/tests/Test_Activator.php index cdde51a..13057a0 100644 --- a/tests/Test_Activator.php +++ b/tests/Test_Activator.php @@ -315,4 +315,56 @@ public function test_version_option_set(): void { $this->assertEquals(ESCALATED_VERSION, get_option('escalated_version')); } + + /** + * maybe_upgrade() is a no-op when the stored version matches current. + */ + public function test_maybe_upgrade_noop_when_version_matches(): void + { + global $wpdb; + + // Drop a table to prove activate() did NOT re-run. + $dropped = $wpdb->prefix.'escalated_departments'; + $wpdb->query("DROP TABLE {$dropped}"); + + Activator::maybe_upgrade(); + + $this->assertNotContains($dropped, $wpdb->get_col('SHOW TABLES')); + } + + /** + * maybe_upgrade() re-runs activate() when the stored version is stale. + */ + public function test_maybe_upgrade_reactivates_when_version_differs(): void + { + global $wpdb; + + update_option('escalated_version', '0.0.0-stale'); + + // Drop a table to prove activate() DID re-run and re-created it. + $dropped = $wpdb->prefix.'escalated_departments'; + $wpdb->query("DROP TABLE {$dropped}"); + + Activator::maybe_upgrade(); + + $this->assertContains($dropped, $wpdb->get_col('SHOW TABLES')); + $this->assertEquals(ESCALATED_VERSION, get_option('escalated_version')); + } + + /** + * maybe_upgrade() reactivates on a fresh install where the option is missing. + */ + public function test_maybe_upgrade_reactivates_when_version_missing(): void + { + global $wpdb; + + delete_option('escalated_version'); + $dropped = $wpdb->prefix.'escalated_departments'; + $wpdb->query("DROP TABLE {$dropped}"); + + Activator::maybe_upgrade(); + + $this->assertContains($dropped, $wpdb->get_col('SHOW TABLES')); + $this->assertEquals(ESCALATED_VERSION, get_option('escalated_version')); + } } From 429df5caeff888079399b91f2d3f4ba5558e4853 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:39:06 -0400 Subject: [PATCH 2/2] fix(test): switch maybe_upgrade tests off DROP TABLE WP_UnitTestCase rewrites DROP TABLE -> DROP TEMPORARY TABLE via its query filter, but dbDelta in the Activator uses a different path, so the tables are real, not temporary. That mismatch made the original drop-and-reactivate assertion flaky. Use a settings-row sentinel instead: deleting the 'ticket_reference_prefix' row proves insert_default_settings() ran (or didn't) without fighting the test framework's table rewriter. --- tests/Test_Activator.php | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/Test_Activator.php b/tests/Test_Activator.php index 13057a0..1a691b5 100644 --- a/tests/Test_Activator.php +++ b/tests/Test_Activator.php @@ -318,18 +318,25 @@ public function test_version_option_set(): void /** * maybe_upgrade() is a no-op when the stored version matches current. + * + * Proof: if activate() re-ran, insert_default_settings() would re-insert + * the 'ticket_reference_prefix' row. Deleting it and then calling + * maybe_upgrade() with a matching version must leave it deleted. */ public function test_maybe_upgrade_noop_when_version_matches(): void { global $wpdb; + $settings_table = $wpdb->prefix.'escalated_settings'; - // Drop a table to prove activate() did NOT re-run. - $dropped = $wpdb->prefix.'escalated_departments'; - $wpdb->query("DROP TABLE {$dropped}"); + $wpdb->delete($settings_table, ['option_key' => 'ticket_reference_prefix']); + $this->assertNull(\Escalated\Models\Setting::get('ticket_reference_prefix')); Activator::maybe_upgrade(); - $this->assertNotContains($dropped, $wpdb->get_col('SHOW TABLES')); + $this->assertNull( + \Escalated\Models\Setting::get('ticket_reference_prefix'), + 'activate() must not have re-run' + ); } /** @@ -338,16 +345,15 @@ public function test_maybe_upgrade_noop_when_version_matches(): void public function test_maybe_upgrade_reactivates_when_version_differs(): void { global $wpdb; + $settings_table = $wpdb->prefix.'escalated_settings'; update_option('escalated_version', '0.0.0-stale'); - - // Drop a table to prove activate() DID re-run and re-created it. - $dropped = $wpdb->prefix.'escalated_departments'; - $wpdb->query("DROP TABLE {$dropped}"); + $wpdb->delete($settings_table, ['option_key' => 'ticket_reference_prefix']); + $this->assertNull(\Escalated\Models\Setting::get('ticket_reference_prefix')); Activator::maybe_upgrade(); - $this->assertContains($dropped, $wpdb->get_col('SHOW TABLES')); + $this->assertEquals('ESC', \Escalated\Models\Setting::get('ticket_reference_prefix')); $this->assertEquals(ESCALATED_VERSION, get_option('escalated_version')); } @@ -357,14 +363,15 @@ public function test_maybe_upgrade_reactivates_when_version_differs(): void public function test_maybe_upgrade_reactivates_when_version_missing(): void { global $wpdb; + $settings_table = $wpdb->prefix.'escalated_settings'; delete_option('escalated_version'); - $dropped = $wpdb->prefix.'escalated_departments'; - $wpdb->query("DROP TABLE {$dropped}"); + $wpdb->delete($settings_table, ['option_key' => 'ticket_reference_prefix']); + $this->assertNull(\Escalated\Models\Setting::get('ticket_reference_prefix')); Activator::maybe_upgrade(); - $this->assertContains($dropped, $wpdb->get_col('SHOW TABLES')); + $this->assertEquals('ESC', \Escalated\Models\Setting::get('ticket_reference_prefix')); $this->assertEquals(ESCALATED_VERSION, get_option('escalated_version')); } }