diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 612d036..7b97508 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,15 @@ 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). +// 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; global $joinBlockLog; 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'; 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:"