From 134692af03214cfac853824a4f079a26f81042c4 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 16:59:51 +0100 Subject: [PATCH 1/4] Add plugin upgrade dispatcher with upgradeJoinFlow entry point Introduces the Upgrade class as the canonical home for one-shot data migrations triggered by version bumps. Modelled on WooCommerce's WC_Install pattern: a static map of plugin-version => migration callbacks, dispatched by Upgrade::check() on `init` when the stored ck_join_flow_db_version is older than CK_JOIN_FLOW_VERSION. There is no native WordPress hook that fires post-upgrade with new code loaded (upgrader_process_complete runs the old code), so the canonical pattern is version-comparison on every request. check() short-circuits on the hot path when versions match. The first migration registered is rekeyMembershipPlanOptions for v1.4.9 which proactively re-keys legacy-format ck_join_flow_membership_plan_* options under the new label_frequency_currency slug, eliminating the need for the runtime fallback shipped in PR #88 to fire on hot lookups. upgradeJoinFlow accepts an injectable migrations map purely so unit tests supply test doubles without mutating static state. Production callers omit the argument. --- packages/join-block/src/Upgrade.php | 161 ++++++++++++ packages/join-block/tests/UpgradeTest.php | 306 ++++++++++++++++++++++ packages/join-block/tests/bootstrap.php | 7 + 3 files changed, 474 insertions(+) create mode 100644 packages/join-block/src/Upgrade.php create mode 100644 packages/join-block/tests/UpgradeTest.php diff --git a/packages/join-block/src/Upgrade.php b/packages/join-block/src/Upgrade.php new file mode 100644 index 0000000..40b8420 --- /dev/null +++ b/packages/join-block/src/Upgrade.php @@ -0,0 +1,161 @@ + ['renameLegacyTagOption'], + * + * 3. Bump CK_JOIN_FLOW_VERSION via scripts/bump-version.sh and ship. + * + * The next request after deploy will run upgradeJoinFlow() once, dispatch each + * pending migration in ascending version order, and stamp the new version. + */ +class Upgrade +{ + /** + * Map of plugin-version => list of migration callbacks to run on upgrade + * to that version. A callback may be a method name on this class (string) + * or any PHP callable. + */ + private static array $migrations = [ + '1.4.9' => ['rekeyMembershipPlanOptions'], + ]; + + /** Transient key for the cross-request upgrade lock. */ + private const LOCK_KEY = 'ck_join_flow_upgrading'; + + /** wp_options key holding the stored plugin db_version. */ + private const VERSION_OPTION = 'ck_join_flow_db_version'; + + /** + * Hooked to the WordPress `init` action. Cheap no-op when the stored + * db_version already matches CK_JOIN_FLOW_VERSION (the common case on + * every page load after a successful upgrade). + * + * The $migrations parameter is injectable purely so unit tests can + * supply test doubles; production callers omit it and use the static + * map. Avoiding a mutable static keeps tests order-independent. + */ + public static function check(?array $migrations = null): void + { + $stored = get_option(self::VERSION_OPTION, '0.0.0'); + if (version_compare($stored, CK_JOIN_FLOW_VERSION, '>=')) { + return; + } + + if (get_transient(self::LOCK_KEY)) { + // Another request in this PHP-FPM pool is already running the + // upgrade. Bail rather than double-run migrations. + return; + } + + // 60s is plenty for the rekey migration; long enough that a slow + // Stripe round-trip doesn't release the lock prematurely, short + // enough that a crashed worker doesn't wedge upgrades indefinitely. + set_transient(self::LOCK_KEY, 1, 60); + + try { + self::upgradeJoinFlow($stored, $migrations); + } finally { + delete_transient(self::LOCK_KEY); + } + } + + /** + * Run every migration in the map whose version key is strictly newer + * than $fromVersion, in ascending version order. Stamps the stored + * db_version only after every migration succeeds — on failure the + * stored version is left stale so the next request retries. + * + * Public so the migration sequence can be triggered manually from WP-CLI + * or a debug tool. Migrations are injectable via the second argument + * solely so unit tests can supply test doubles; production callers omit + * it and use self::$migrations. + */ + public static function upgradeJoinFlow(string $fromVersion, ?array $migrations = null): void + { + global $joinBlockLog; + + $migrations = $migrations ?? self::$migrations; + + $pending = []; + foreach ($migrations as $version => $callbacks) { + if (version_compare($fromVersion, $version, '<')) { + $pending[$version] = $callbacks; + } + } + uksort($pending, 'version_compare'); + + if (!$pending) { + // No migrations to run, but the version may still need stamping + // (e.g. fresh install or a no-migration version bump). + update_option(self::VERSION_OPTION, CK_JOIN_FLOW_VERSION); + return; + } + + if ($joinBlockLog) { + $joinBlockLog->info( + "Running join-flow upgrade from {$fromVersion} to " . CK_JOIN_FLOW_VERSION, + ['pending' => array_keys($pending)] + ); + } + + foreach ($pending as $version => $callbacks) { + foreach ($callbacks as $callback) { + $callable = is_string($callback) ? [self::class, $callback] : $callback; + if ($joinBlockLog) { + $label = is_string($callback) ? $callback : 'closure'; + $joinBlockLog->info("Running migration for {$version}: {$label}"); + } + call_user_func($callable); + } + } + + update_option(self::VERSION_OPTION, CK_JOIN_FLOW_VERSION); + + if ($joinBlockLog) { + $joinBlockLog->info('Join-flow upgrade complete; db_version=' . CK_JOIN_FLOW_VERSION); + } + } + + /** + * Migration for v1.4.9: re-key all stored membership plans under the + * new label_frequency_currency slug format introduced in v1.4.4. + * + * Idempotent — running this on already-correctly-keyed plans is a no-op + * because saveMembershipPlans() writes to the same key it reads from. + * Old-format option rows are deliberately not deleted; the + * getMembershipPlan() fallback continues to read them as a safety net. + */ + private static function rekeyMembershipPlanOptions(): void + { + $plans = Settings::get('MEMBERSHIP_PLANS') ?? []; + if (!$plans) { + return; + } + Settings::saveMembershipPlans($plans); + } + + /** @internal Used by UpgradeTest to assert the production map's contents. */ + public static function getMigrationsForTesting(): array + { + return self::$migrations; + } +} diff --git a/packages/join-block/tests/UpgradeTest.php b/packages/join-block/tests/UpgradeTest.php new file mode 100644 index 0000000..36b410d --- /dev/null +++ b/packages/join-block/tests/UpgradeTest.php @@ -0,0 +1,306 @@ +once() + ->with('ck_join_flow_db_version', '0.0.0') + ->andReturn(CK_JOIN_FLOW_VERSION); + + // No transient or update_option calls expected — if the dispatcher + // touched them, Brain Monkey's strict mode would fail the test. + Monkey\Functions\expect('get_transient')->never(); + Monkey\Functions\expect('update_option')->never(); + + Upgrade::check(); + + $this->assertTrue(true); // assertions are in the expect() calls + } + + /** + * When the stored version is older than the current plugin version, the + * dispatcher must invoke upgradeJoinFlow with the stored version as the + * fromVersion, run the production migrations, and stamp the new version. + * + * We supply an injected migration map via a static override so the test + * doesn't depend on real migration behaviour. + */ + public function testCheckRunsUpgradeJoinFlowWhenStoredVersionIsOlder(): void + { + Monkey\Functions\expect('get_option') + ->once() + ->with('ck_join_flow_db_version', '0.0.0') + ->andReturn('1.4.8'); + + Monkey\Functions\expect('get_transient') + ->with('ck_join_flow_upgrading') + ->andReturn(false); + Monkey\Functions\expect('set_transient')->once(); + Monkey\Functions\expect('delete_transient')->once(); + + Monkey\Functions\expect('update_option') + ->once() + ->with('ck_join_flow_db_version', CK_JOIN_FLOW_VERSION) + ->andReturn(true); + + $invoked = false; + Upgrade::check([ + CK_JOIN_FLOW_VERSION => [function () use (&$invoked) { + $invoked = true; + }], + ]); + + $this->assertTrue($invoked, 'Migration callback should have been invoked'); + } + + /** + * Fresh-install case: get_option returns the default '0.0.0' when no + * stored value exists. The dispatcher must treat that as older than any + * real version and run all migrations. + */ + public function testCheckTreatsMissingStoredVersionAsZero(): void + { + Monkey\Functions\expect('get_option') + ->once() + ->with('ck_join_flow_db_version', '0.0.0') + ->andReturn('0.0.0'); + + Monkey\Functions\expect('get_transient')->andReturn(false); + Monkey\Functions\expect('set_transient'); + Monkey\Functions\expect('delete_transient'); + Monkey\Functions\expect('update_option') + ->once() + ->with('ck_join_flow_db_version', CK_JOIN_FLOW_VERSION); + + Upgrade::check([]); // no migrations to run; just stamp + + $this->assertTrue(true); + } + + // ------------------------------------------------------------------ + // check() — concurrent-invocation lock + // ------------------------------------------------------------------ + + /** + * If a previous request is mid-upgrade (transient lock held), a second + * concurrent check() must be a no-op so we don't double-run migrations + * or hit Stripe twice. Cheap protection against the WordPress request + * concurrency model on busy sites. + */ + public function testCheckIsNoOpWhenUpgradeLockIsHeld(): void + { + Monkey\Functions\expect('get_option') + ->with('ck_join_flow_db_version', '0.0.0') + ->andReturn('1.4.8'); + + // Lock is already held by another request. + Monkey\Functions\expect('get_transient') + ->with('ck_join_flow_upgrading') + ->andReturn(true); + + // Must NOT touch any of these — another request owns the upgrade. + Monkey\Functions\expect('set_transient')->never(); + Monkey\Functions\expect('delete_transient')->never(); + Monkey\Functions\expect('update_option')->never(); + + $invoked = false; + Upgrade::check([ + CK_JOIN_FLOW_VERSION => [function () use (&$invoked) { + $invoked = true; + }], + ]); + + $this->assertFalse($invoked, 'Migration must not run while lock is held'); + } + + // ------------------------------------------------------------------ + // upgradeJoinFlow() — version filtering and ordering + // ------------------------------------------------------------------ + + /** + * Migrations keyed at or below $fromVersion have already run on a + * previous upgrade and must be skipped — running them again could + * double-charge Stripe or duplicate option writes. + */ + public function testUpgradeJoinFlowSkipsMigrationsAtOrBelowFromVersion(): void + { + Monkey\Functions\expect('update_option')->once()->andReturn(true); + + $alreadyRan = false; + $shouldRun = false; + + Upgrade::upgradeJoinFlow('1.4.9', [ + '1.4.9' => [function () use (&$alreadyRan) { + $alreadyRan = true; + }], + '1.5.0' => [function () use (&$shouldRun) { + $shouldRun = true; + }], + ]); + + $this->assertFalse($alreadyRan, '1.4.9 migration should NOT re-run when from=1.4.9'); + $this->assertTrue($shouldRun, '1.5.0 migration SHOULD run when from=1.4.9'); + } + + /** + * Migrations must run in ascending version order. A 1.4.9 → 1.5.1 + * upgrade must run 1.5.0's migration before 1.5.1's, even if the map + * is declared out of order. + */ + public function testUpgradeJoinFlowRunsMigrationsInAscendingVersionOrder(): void + { + Monkey\Functions\expect('update_option')->once()->andReturn(true); + + $callOrder = []; + + Upgrade::upgradeJoinFlow('1.4.9', [ + '1.5.1' => [function () use (&$callOrder) { + $callOrder[] = '1.5.1'; + }], + '1.5.0' => [function () use (&$callOrder) { + $callOrder[] = '1.5.0'; + }], + ]); + + $this->assertSame(['1.5.0', '1.5.1'], $callOrder); + } + + /** + * If a migration throws, the dispatcher must NOT bump the stored + * version — leaving it stale ensures the next request retries the + * migration, surfacing the failure instead of silently skipping it. + * The exception must propagate so monitoring (Sentry, Teams) catches it. + */ + public function testUpgradeJoinFlowDoesNotBumpVersionWhenMigrationThrows(): void + { + // No update_option call expected on the failure path. + Monkey\Functions\expect('update_option')->never(); + + $this->expectException(RuntimeException::class); + + Upgrade::upgradeJoinFlow('1.4.8', [ + '1.4.9' => [function () { + throw new RuntimeException('migration boom'); + }], + ]); + } + + /** + * Happy path: when every migration succeeds, the stored version is + * stamped to CK_JOIN_FLOW_VERSION exactly once. + */ + public function testUpgradeJoinFlowStampsVersionAfterAllMigrationsSucceed(): void + { + Monkey\Functions\expect('update_option') + ->once() + ->with('ck_join_flow_db_version', CK_JOIN_FLOW_VERSION) + ->andReturn(true); + + // Two distinct version keys so both callbacks run regardless of what + // CK_JOIN_FLOW_VERSION currently is. + $ranA = false; + $ranB = false; + + Upgrade::upgradeJoinFlow('1.4.8', [ + '1.4.9' => [function () use (&$ranA) { + $ranA = true; + }], + '1.5.0' => [function () use (&$ranB) { + $ranB = true; + }], + ]); + + $this->assertTrue($ranA); + $this->assertTrue($ranB); + } + + // ------------------------------------------------------------------ + // Production migration map sanity check + // ------------------------------------------------------------------ + + /** + * Sanity check that the production migration map declares the 1.4.9 + * rekey migration. Pins the map's contract — if someone deletes the + * entry, this fails and forces them to think about why. + */ + public function testProductionMigrationMapIncludesRekeyForOneFourNine(): void + { + $map = Upgrade::getMigrationsForTesting(); + + $this->assertArrayHasKey('1.4.9', $map); + $this->assertContains( + 'rekeyMembershipPlanOptions', + array_map( + fn($entry) => is_array($entry) ? ($entry[1] ?? $entry[0]) : $entry, + $map['1.4.9'] + ) + ); + } +} diff --git a/packages/join-block/tests/bootstrap.php b/packages/join-block/tests/bootstrap.php index de06b5a..0892f65 100644 --- a/packages/join-block/tests/bootstrap.php +++ b/packages/join-block/tests/bootstrap.php @@ -10,4 +10,11 @@ define('ARRAY_A', 'ARRAY_A'); } +// Plugin version. Production code reads this from the matching define() in +// join.php; tests pin it here so Upgrade can compare against a known value +// without bootstrapping the whole plugin. +if (!defined('CK_JOIN_FLOW_VERSION')) { + define('CK_JOIN_FLOW_VERSION', '1.4.9'); +} + require_once __DIR__ . '/../vendor/autoload.php'; From e2151f795be8eee8aefc0ebb8b0709f7aa90ec64 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 17:00:00 +0100 Subject: [PATCH 2/4] Define CK_JOIN_FLOW_VERSION and wire Upgrade::check on init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the version constant alongside the plugin-header version so PHP code can reference it (the plugin header is parseable only via get_file_data, which is wasteful to do per-request). Hooks Upgrade::check to `init` rather than `plugins_loaded` because the rekey migration calls Settings::get('MEMBERSHIP_PLANS'), which depends on Carbon Fields theme options — Carbon Fields boots on after_setup_theme, so init is the first hook where settings are safe to read. --- packages/join-block/join.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 612d036..e9c794e 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -13,6 +13,10 @@ exit; // Exit if accessed directly } +if (!defined('CK_JOIN_FLOW_VERSION')) { + define('CK_JOIN_FLOW_VERSION', '1.4.9'); +} + require_once plugin_dir_path(__FILE__) . 'vendor/autoload.php'; use ChargeBee\ChargeBee\Environment; @@ -28,6 +32,7 @@ use CommonKnowledge\JoinBlock\Services\MailchimpService; use CommonKnowledge\JoinBlock\Services\ZetkinService; use CommonKnowledge\JoinBlock\Settings; +use CommonKnowledge\JoinBlock\Upgrade; Logging::init(); global $joinBlockLog; @@ -699,6 +704,11 @@ Environment::configure($chargebee_site_name, $chargebee_api_key); }); +// Run any pending data migrations after a plugin version bump. Hooked to +// `init` so Carbon Fields theme options are populated when migrations run +// (Settings::get depends on Carbon Fields, which boots on after_setup_theme). +add_action('init', [Upgrade::class, 'check']); + add_action('ck_join_block_gocardless_cron_hook', function () { global $wpdb; global $joinBlockLog; From 51f9010b680148cecace7f87f85c55b5d7662ff3 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 17:00:09 +0100 Subject: [PATCH 3/4] Bump CK_JOIN_FLOW_VERSION constant and readme.md in bump-version.sh The script previously touched join.php's header, readme.txt, and index.tsx but missed the new CK_JOIN_FLOW_VERSION constant and the top-level readme.md's **Current version:** line. Both now bump together so the script remains the single source of truth for version-touching files. readme.md was bumped by hand during the 1.4.9 release because the script didn't know about it; folding it in here prevents future drift. --- scripts/bump-version.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 02dc136..6adb34a 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -18,6 +18,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" README_TXT="$PROJECT_ROOT/packages/join-block/readme.txt" +README_MD="$PROJECT_ROOT/readme.md" JOIN_PHP="$PROJECT_ROOT/packages/join-block/join.php" INDEX_TSX="$PROJECT_ROOT/packages/join-flow/src/index.tsx" @@ -26,6 +27,11 @@ if [ ! -f "$README_TXT" ]; then exit 1 fi +if [ ! -f "$README_MD" ]; then + print_error "readme.md not found at $README_MD" + exit 1 +fi + if [ ! -f "$JOIN_PHP" ]; then print_error "join.php not found at $JOIN_PHP" exit 1 @@ -73,7 +79,7 @@ if [ ${#CHANGELOG_ENTRIES[@]} -eq 0 ]; then print_warning "No changelog entries provided" fi -print_step "Updating version to $NEW_VERSION in 3 files..." +print_step "Updating version to $NEW_VERSION in 4 files..." print_step "1. Updating readme.txt (Stable tag and changelog)" sed -i.bak "s/^Stable tag: .*/Stable tag: $NEW_VERSION/" "$README_TXT" @@ -104,11 +110,16 @@ fi rm -f "$README_TXT.bak" -print_step "2. Updating join.php (Version)" +print_step "2. Updating join.php (Version header and CK_JOIN_FLOW_VERSION constant)" sed -i.bak "s/^ \* Version: .*/ * Version: $NEW_VERSION/" "$JOIN_PHP" +sed -i.bak "s/define('CK_JOIN_FLOW_VERSION', '[^']*')/define('CK_JOIN_FLOW_VERSION', '$NEW_VERSION')/" "$JOIN_PHP" rm -f "$JOIN_PHP.bak" -print_step "3. Updating index.tsx (Sentry release)" +print_step "3. Updating readme.md (Current version)" +sed -i.bak "s/\*\*Current version:\*\* .*/\*\*Current version:\*\* $NEW_VERSION/" "$README_MD" +rm -f "$README_MD.bak" + +print_step "4. Updating index.tsx (Sentry release)" sed -i.bak "s/release: \".*\"/release: \"$NEW_VERSION\"/" "$INDEX_TSX" rm -f "$INDEX_TSX.bak" @@ -117,6 +128,7 @@ echo "" echo "Files updated:" echo " - packages/join-block/readme.txt" echo " - packages/join-block/join.php" +echo " - readme.md" echo " - packages/join-flow/src/index.tsx" echo "" echo "Next steps:" From d5124821d39c954b520a55cf479407b494f03903 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 17:10:33 +0100 Subject: [PATCH 4/4] Wrap Upgrade::check in closure when registering init action WordPress's WP_Hook calls registered callbacks with accepted_args=1 by default, even when the action itself fires with no args. For init, this means the callback receives an empty string positional arg. Upgrade::check declares ?array $migrations as its first parameter (for test injection), so PHP's strict type check rejected the empty string and threw on every WP-CLI invocation, breaking the e2e suite. Match the existing init handler's pattern by registering a no-arg closure that calls check() without forwarding WP's spurious arg. --- packages/join-block/join.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index e9c794e..7b97508 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -707,7 +707,11 @@ // Run any pending data migrations after a plugin version bump. Hooked to // `init` so Carbon Fields theme options are populated when migrations run // (Settings::get depends on Carbon Fields, which boots on after_setup_theme). -add_action('init', [Upgrade::class, 'check']); +// Wrapped in a closure so WordPress's default accepted_args=1 doesn't pass +// an empty-string positional arg into check()'s ?array parameter. +add_action('init', function () { + Upgrade::check(); +}); add_action('ck_join_block_gocardless_cron_hook', function () { global $wpdb;