diff --git a/.claude/skills/dokan-settings/SKILL.md b/.claude/skills/dokan-settings/SKILL.md index 761f9c1ba9..24562e4635 100644 --- a/.claude/skills/dokan-settings/SKILL.md +++ b/.claude/skills/dokan-settings/SKILL.md @@ -46,6 +46,15 @@ The new admin settings system has three layers. Knowing which layer owns what sa - Field IDs MUST be globally unique. `SchemaValidator::check_unique_field_ids()` enforces this hard. - A `fieldgroup` element MAY share an `id` with its inner `field` (existing pattern, e.g., `google_map_api_key`). Only `type === 'field'` enters the uniqueness check. +### Legacy Mirror (downgrade safety) + +- `dokan_admin_settings` is canonical, but with the **legacy mirror** enabled (default) the legacy `dokan_*` rows keep a physical copy of every mapped value: + - `LegacyMirror` (Hookable, `includes/Admin/Settings/Migration/LegacyMirror.php`) listens on `dokan_admin_settings_changed` and writes changed values through to the legacy rows via `LegacySettingsBridge::write_new_to_legacy()`. + - Legacy-section saves (`dokan_save_legacy_settings_section()`) no longer strip mapped keys from the row. + - On `admin_init`, `LegacyMirror::maybe_reconcile()` compares the raw rows against a baseline snapshot (`dokan_admin_settings_legacy_snapshot`) and adopts keys an OLD plugin version edited while the bridge was absent (downgrade → edit → re-upgrade). Last write wins. +- Single-line switch back to the strict single-source model (strip on, mirror + reconcile off): `add_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' );` +- Pitfall: raw-row writes must run inside `BridgeBootstrap::without_overlay()` — with the overlay active, `update_option()`'s internal old-value read is projected from the flat option, so a physically-stale row looks current and the write silently no-ops. + ## Adding a New Setting Field 1. **Declare in `SettingsSchema.php`** under the appropriate page's helper: diff --git a/includes/Admin/Settings/Migration/BridgeBootstrap.php b/includes/Admin/Settings/Migration/BridgeBootstrap.php index 72dd82f88b..cdeaf40cfa 100644 --- a/includes/Admin/Settings/Migration/BridgeBootstrap.php +++ b/includes/Admin/Settings/Migration/BridgeBootstrap.php @@ -15,9 +15,12 @@ * * The write side is handled at each writer (via * {@see LegacySettingsBridge::persist_legacy_section()}): mapped keys land - * in `dokan_admin_settings` only; the legacy row holds unmapped keys only. - * No reverse mirror is installed — that would defeat the single-source - * invariant. + * in `dokan_admin_settings`. When the legacy mirror is enabled (default — + * see {@see LegacySettingsBridge::is_legacy_mirror_enabled()}) the legacy + * row also keeps a physical copy of mapped values, maintained by + * {@see LegacyMirror}, so plugin versions without this bridge (downgrades) + * still read current data. With the mirror disabled the legacy row holds + * unmapped keys only and the flat option is the single physical source. * * Reentry latch: the bridge's own internal reads (build_map, hydrate*) * must see raw legacy data, not the overlay; the static guard short- @@ -58,6 +61,31 @@ public function __construct( ?LegacySettingsBridge $bridge = null ) { $this->bridge = $bridge; } + /** + * Run a callback with the overlay filters suppressed. + * + * Required by writers that must observe and persist the RAW legacy rows: + * with the overlay active, `update_option()`'s internal old-value read is + * projected too, so a physically-stale row can look up-to-date and the + * write silently no-ops. The latch is saved/restored so nested calls are + * safe. + * + * @since DOKAN_SINCE + * + * @param callable $callback Callback to run without the overlay. + * + * @return mixed The callback's return value. + */ + public static function without_overlay( callable $callback ) { + $previous = self::$in_overlay; + self::$in_overlay = true; + try { + return $callback(); + } finally { + self::$in_overlay = $previous; + } + } + /** * Lazily resolve the bridge. * diff --git a/includes/Admin/Settings/Migration/LegacyMirror.php b/includes/Admin/Settings/Migration/LegacyMirror.php new file mode 100644 index 0000000000..8bd81f5e2d --- /dev/null +++ b/includes/Admin/Settings/Migration/LegacyMirror.php @@ -0,0 +1,296 @@ +bridge = $bridge; + $this->settings_repo = $settings_repo; + } + + /** + * {@inheritDoc} + */ + public function register_hooks(): void { + add_action( 'dokan_admin_settings_changed', [ $this, 'mirror_changes' ] ); + // After BridgeBootstrap's overlay wiring (init@999) so the mapping is + // complete; admin-only because divergence can only be observed and + // fixed when someone is managing the site anyway. + add_action( 'admin_init', [ $this, 'maybe_reconcile' ], 5 ); + } + + /** + * Write-through: mirror a changed flat-option slice into the legacy rows. + * + * Listens on `dokan_admin_settings_changed`, so every internal save path + * (REST controller, legacy section saves, reconciliation) keeps the rows + * current. `null` entries (key removals reported by `replace()`) are + * skipped — the stale legacy leaf is harmless because reads stay + * overlay-projected. + * + * @since DOKAN_SINCE + * + * @param array $changed Added/modified flat-option entries. + * + * @return void + */ + public function mirror_changes( array $changed ): void { + if ( ! LegacySettingsBridge::is_legacy_mirror_enabled() ) { + return; + } + $bridge = $this->resolve_bridge(); + if ( ! $bridge instanceof LegacySettingsBridge ) { + return; + } + $slice = array_filter( + $changed, + static function ( $value ) { + return null !== $value; + } + ); + try { + if ( ! empty( $slice ) ) { + BridgeBootstrap::without_overlay( + static function () use ( $bridge, $slice ) { + return $bridge->write_new_to_legacy( $slice ); + } + ); + } + $this->stamp(); + } catch ( \Throwable $e ) { + if ( function_exists( 'dokan_log' ) ) { + dokan_log( '[LegacyMirror] write-through failed: ' . $e->getMessage() ); + } + } + } + + /** + * Reconcile edits made while the bridge was absent (downgrade window). + * + * Compares the raw legacy rows against the stored baseline snapshot. + * A key whose raw legacy value changed since the baseline was written + * by something without the bridge — an older plugin version — so its + * value is adopted into `dokan_admin_settings` (last write wins). + * + * First run (no baseline yet): materializes the full mirror into the + * legacy rows, healing rows stripped under the old strict model, then + * stamps the baseline. + * + * Keys absent from the baseline are never adopted: they are either + * newly-mapped schema fields (where the flat option may hold the newer + * value) or rows the lazy read-time hydration already serves live. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function maybe_reconcile(): void { + if ( ! LegacySettingsBridge::is_legacy_mirror_enabled() ) { + return; + } + $bridge = $this->resolve_bridge(); + $repo = $this->resolve_settings_repo(); + if ( ! $bridge instanceof LegacySettingsBridge || null === $repo ) { + return; + } + + try { + $current = $this->snapshot_legacy_as_new(); + $stored = get_option( self::SNAPSHOT_KEY, null ); + + if ( ! is_array( $stored ) ) { + // First run: make the rows a full physical mirror, then baseline. + $payload = $repo->all(); + if ( ! empty( $payload ) ) { + BridgeBootstrap::without_overlay( + static function () use ( $bridge, $payload ) { + return $bridge->write_new_to_legacy( $payload ); + } + ); + } + $this->stamp(); + return; + } + + if ( $stored === $current ) { + return; + } + + $flat = $repo->all(); + $adopt = []; + foreach ( $current as $key => $value ) { + if ( ! array_key_exists( $key, $stored ) || $stored[ $key ] === $value ) { + continue; + } + if ( array_key_exists( $key, $flat ) && $flat[ $key ] === $value ) { + continue; + } + $adopt[ $key ] = $value; + } + + if ( ! empty( $adopt ) ) { + // Fires dokan_admin_settings_changed → mirror_changes() → stamp(). + $repo->update( $adopt ); + } + $this->stamp( $current ); + } catch ( \Throwable $e ) { + if ( function_exists( 'dokan_log' ) ) { + dokan_log( '[LegacyMirror] reconciliation failed: ' . $e->getMessage() ); + } + } + } + + /** + * Store the baseline snapshot of the mapped legacy values. + * + * @since DOKAN_SINCE + * + * @param array|null $snapshot Precomputed snapshot, or null to recompute. + * + * @return void + */ + public function stamp( ?array $snapshot = null ): void { + update_option( self::SNAPSHOT_KEY, $snapshot ?? $this->snapshot_legacy_as_new(), true ); + } + + /** + * Project the RAW legacy rows into new-key space. + * + * Reads every mapped section with the overlay suppressed and transforms + * the mapped values through the bridge, producing a deterministic + * `field_id => value` map suitable for baseline comparison and adoption. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function snapshot_legacy_as_new(): array { + $bridge = $this->resolve_bridge(); + if ( ! $bridge instanceof LegacySettingsBridge ) { + return []; + } + $snapshot = BridgeBootstrap::without_overlay( + static function () use ( $bridge ) { + $result = []; + foreach ( $bridge->known_sections() as $section ) { + $raw = get_option( $section, [] ); + if ( ! is_array( $raw ) || empty( $raw ) ) { + continue; + } + $result += $bridge->transform_legacy_payload_to_new( $section, $raw ); + } + return $result; + } + ); + ksort( $snapshot ); + return $snapshot; + } + + /** + * Lazily resolve the bridge. + * + * @return LegacySettingsBridge|null + */ + private function resolve_bridge(): ?LegacySettingsBridge { + if ( $this->bridge instanceof LegacySettingsBridge ) { + return $this->bridge; + } + if ( function_exists( 'dokan_get_container' ) ) { + try { + $resolved = dokan_get_container()->get( LegacySettingsBridge::class ); + if ( $resolved instanceof LegacySettingsBridge ) { + $this->bridge = $resolved; + return $this->bridge; + } + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + unset( $e ); + } + } + return null; + } + + /** + * Lazily resolve the flat-option repository. + * + * @return SettingsRepositoryInterface|null + */ + private function resolve_settings_repo(): ?SettingsRepositoryInterface { + if ( $this->settings_repo instanceof SettingsRepositoryInterface ) { + return $this->settings_repo; + } + if ( function_exists( 'dokan_get_container' ) ) { + try { + $resolved = dokan_get_container()->get( SettingsRepository::class ); + if ( $resolved instanceof SettingsRepositoryInterface ) { + $this->settings_repo = $resolved; + return $this->settings_repo; + } + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + unset( $e ); + } + } + return null; + } +} diff --git a/includes/Admin/Settings/Migration/LegacySettingsBridge.php b/includes/Admin/Settings/Migration/LegacySettingsBridge.php index b93270b708..93de77a2ea 100644 --- a/includes/Admin/Settings/Migration/LegacySettingsBridge.php +++ b/includes/Admin/Settings/Migration/LegacySettingsBridge.php @@ -113,6 +113,47 @@ public function __construct( ?SettingsRepositoryInterface $settings_repo = null $this->settings_repo = $settings_repo ?? new SettingsRepository(); } + /** + * Whether legacy rows are kept as a full, downgrade-safe mirror. + * + * Enabled (default): mapped values are written through to the legacy + * `dokan_*` rows on every save ({@see LegacyMirror}), legacy payloads are + * NOT stripped of mapped keys, and edits made by an older plugin version + * (which knows nothing about `dokan_admin_settings`) are reconciled back + * into the flat option on re-upgrade. Disabled: the pre-mirror strict + * model — mapped keys live only in `dokan_admin_settings` and legacy rows + * hold the unmapped remainder. + * + * Single-line switch: `add_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' );` + * + * @since DOKAN_SINCE + * + * @return bool + */ + public static function is_legacy_mirror_enabled(): bool { + /** + * Filter whether the downgrade-safe legacy mirror is enabled. + * + * @since DOKAN_SINCE + * + * @param bool $enabled Default true. + */ + return (bool) apply_filters( 'dokan_admin_settings_legacy_mirror', true ); + } + + /** + * Unique legacy wp_option names the current mapping refers to, including + * options reached only through multi-slot (1:N) mappings. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function known_sections(): array { + $this->build_map(); + return array_keys( $this->by_option ?? [] ); + } + /** * Flush the in-request caches held by this bridge and its collaborators. * @@ -354,24 +395,24 @@ public function strip_mapped_keys( string $option_name, array $payload ): array /** * Persist a legacy section payload through the bridge: * 1. Mirror mapped keys into the new flat option. - * 2. Strip those mapped keys from the payload. + * 2. When the legacy mirror is disabled, strip those mapped keys + * from the payload; when enabled (default), keep them so the + * legacy row stays a downgrade-safe physical copy. * - * Returns the stripped payload; the caller is responsible for the + * Returns the payload to write; the caller is responsible for the * `update_option( $option_name, ... )` write. The split keeps callers * in control of side effects (do_action hooks, cache flushes, etc.) - * while centralizing the strip + mirror logic. + * while centralizing the mirror logic. * - * Strict mode: stripping happens unconditionally. If the new-option - * write throws, we log and continue — the mapped values are lost from - * this save, but the legacy row never holds mapped data. Source of - * truth stays single. + * If the new-option write throws, we log and continue — the legacy row + * write still proceeds so the save is not lost entirely. * * @since DOKAN_SINCE * * @param string $option_name Legacy wp_option name. * @param array $payload Legacy-shaped payload. * - * @return array Stripped payload, safe to `update_option`. + * @return array Payload safe to `update_option`. */ public function persist_legacy_section( string $option_name, array $payload ): array { try { @@ -384,7 +425,7 @@ public function persist_legacy_section( string $option_name, array $payload ): a dokan_log( '[LegacySettingsBridge] persist_legacy_section new-write failed: ' . $e->getMessage() ); } } - return $this->strip_mapped_keys( $option_name, $payload ); + return self::is_legacy_mirror_enabled() ? $payload : $this->strip_mapped_keys( $option_name, $payload ); } /** diff --git a/includes/Admin/Settings/Repository/LegacySettingsRepository.php b/includes/Admin/Settings/Repository/LegacySettingsRepository.php index 5bf68812aa..338b9801a7 100644 --- a/includes/Admin/Settings/Repository/LegacySettingsRepository.php +++ b/includes/Admin/Settings/Repository/LegacySettingsRepository.php @@ -84,17 +84,21 @@ public function update( string $section, array $slice ): array { return []; } - // Route the incoming slice through the bridge: mapped keys go to the - // new flat option only; the legacy row holds unmapped keys only. - $stripped_slice = $this->bridge->persist_legacy_section( $section, $slice ); - - $raw = get_option( $section, [] ); - $raw = is_array( $raw ) ? $raw : []; - // Defensive: a previously-stored legacy row may still hold mapped keys - // (lazy migration policy — we never backfill on read). Strip them on - // every write so the saved row converges on the invariant. - $raw = $this->bridge->strip_mapped_keys( $section, $raw ); - $merged = array_merge( $raw, $stripped_slice ); + // Route the incoming slice through the bridge: mapped keys are + // mirrored into the new flat option; with the legacy mirror enabled + // (default) they also stay in the legacy row so a downgraded plugin + // still reads current data, otherwise they are stripped out. + $persistable_slice = $this->bridge->persist_legacy_section( $section, $slice ); + + $raw = get_option( $section, [] ); + $raw = is_array( $raw ) ? $raw : []; + if ( ! LegacySettingsBridge::is_legacy_mirror_enabled() ) { + // Strict mode: a previously-stored legacy row may still hold mapped + // keys. Strip them on every write so the row converges on the + // mapped-keys-live-only-in-the-flat-option invariant. + $raw = $this->bridge->strip_mapped_keys( $section, $raw ); + } + $merged = array_merge( $raw, $persistable_slice ); update_option( $section, $merged, true ); // Refresh our snapshot now — the WP hook already flushed it, but we want @@ -129,13 +133,13 @@ public function replace( string $section, array $payload ): array { } } - // `replace` is a full-row write, so mapped keys must be peeled off - // before we land the legacy option. The bridge mirrors them into the - // new flat option as a side effect. - $stripped_payload = $this->bridge->persist_legacy_section( $section, $payload ); + // `replace` is a full-row write. The bridge mirrors mapped keys into + // the new flat option as a side effect; with the legacy mirror enabled + // (default) they also stay in the row, otherwise they are peeled off. + $persistable_payload = $this->bridge->persist_legacy_section( $section, $payload ); - update_option( $section, $stripped_payload, true ); - $this->snapshots[ $section ] = $this->bridge->hydrate_legacy_from_new( $section, $stripped_payload ); + update_option( $section, $persistable_payload, true ); + $this->snapshots[ $section ] = $this->bridge->hydrate_legacy_from_new( $section, $persistable_payload ); if ( ! empty( $diff ) ) { /** This action is documented in includes/Admin/Settings/Repository/LegacySettingsRepository.php */ @@ -186,14 +190,7 @@ public function flush_all_snapshots(): void { * @return array */ private function known_sections(): array { - $map = $this->bridge->get_mapping(); - $sections = []; - foreach ( $map as $entry ) { - if ( is_array( $entry ) && isset( $entry['option'] ) && is_string( $entry['option'] ) ) { - $sections[ $entry['option'] ] = true; - } - } - return array_keys( $sections ); + return $this->bridge->known_sections(); } /** diff --git a/includes/DependencyManagement/Providers/AdminSettingsServiceProvider.php b/includes/DependencyManagement/Providers/AdminSettingsServiceProvider.php index 032d8b11e2..ec5b291a98 100644 --- a/includes/DependencyManagement/Providers/AdminSettingsServiceProvider.php +++ b/includes/DependencyManagement/Providers/AdminSettingsServiceProvider.php @@ -3,6 +3,7 @@ namespace WeDevs\Dokan\DependencyManagement\Providers; use WeDevs\Dokan\Admin\Settings\Migration\BridgeBootstrap; +use WeDevs\Dokan\Admin\Settings\Migration\LegacyMirror; use WeDevs\Dokan\Admin\Settings\Migration\LegacySettingsBridge; use WeDevs\Dokan\Admin\Settings\Repository\LegacySettingsRepository; use WeDevs\Dokan\Admin\Settings\Repository\LegacySettingsRepositoryInterface; @@ -28,6 +29,7 @@ class AdminSettingsServiceProvider extends BaseServiceProvider { LegacySettingsBridge::class, LegacySettingsRepository::class, BridgeBootstrap::class, + LegacyMirror::class, ]; /** diff --git a/includes/functions.php b/includes/functions.php index eecff5b25d..1b8fea9aa1 100755 --- a/includes/functions.php +++ b/includes/functions.php @@ -1097,9 +1097,11 @@ function dokan_get_option( $option, $section, $default_value = '' ) { * which: * 1. Mirrors mapped keys into the new flat `dokan_admin_settings` option * (the canonical source of truth). - * 2. Strips those mapped keys from the payload. - * 3. Persists the unmapped remainder under the legacy `$option_name` row. - * 4. Fires `dokan_legacy_settings_changed` and refreshes the in-request snapshot. + * 2. Persists the payload under the legacy `$option_name` row. With the + * legacy mirror enabled (default) mapped keys stay in the row so a + * downgraded plugin still reads current data; with it disabled they + * are stripped and only the unmapped remainder is stored. + * 3. Fires `dokan_legacy_settings_changed` and refreshes the in-request snapshot. * * Callers should NOT also call `update_option( $option_name, ... )` — the * repository owns that write. If the DI container is unavailable (early diff --git a/tests/php/src/Admin/Settings/DokanSaveLegacySettingsSectionTest.php b/tests/php/src/Admin/Settings/DokanSaveLegacySettingsSectionTest.php index 2efadee891..ef24c70fc7 100644 --- a/tests/php/src/Admin/Settings/DokanSaveLegacySettingsSectionTest.php +++ b/tests/php/src/Admin/Settings/DokanSaveLegacySettingsSectionTest.php @@ -6,15 +6,15 @@ /** * Round-trip behavior of dokan_save_legacy_settings_section(): mapped keys - * land in the new flat option (canonical), the legacy row holds the unmapped - * remainder only, and a fresh get_option() read returns the synthesized - * full view. + * land in the new flat option (canonical) AND — with the legacy mirror on by + * default — stay physically in the legacy row so a downgraded plugin version + * still reads current data. * * @group admin-settings */ class DokanSaveLegacySettingsSectionTest extends DokanTestCase { - public function test_round_trip_strips_legacy_row_and_overlays_on_read(): void { + public function test_round_trip_keeps_mapped_key_in_legacy_row_and_in_new_option(): void { $cb = static function (): array { return [ [ @@ -40,8 +40,8 @@ public function test_round_trip_strips_legacy_row_and_overlays_on_read(): void { // Read the raw row directly via $wpdb — `get_option()` would route // through BridgeBootstrap's overlay filter and re-inject the mapped - // key from the new flat option, masking whether the leaf actually - // got stripped from storage. + // key from the new flat option, masking what is physically stored + // (which is exactly what a downgraded plugin version would read). global $wpdb; $raw_serialized = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'dokan_general' ) @@ -54,13 +54,14 @@ public function test_round_trip_strips_legacy_row_and_overlays_on_read(): void { delete_option( 'dokan_admin_settings' ); $this->assertSame( 'off', $new_settings['vendor_welcome_wizard_enabled'] ); - // The legacy row must not retain the mapped key after the strip. + // The legacy mirror (default) keeps the mapped key physically in the + // row so an old plugin version without the bridge reads current data. $this->assertIsArray( $raw_legacy ); $this->assertSame( 'store7', $raw_legacy['custom_store_url'] ); - $this->assertArrayNotHasKey( - 'disable_welcome_wizard', - (array) $raw_legacy, - 'Mapped key must not persist in the legacy row.' + $this->assertSame( + 'off', + $raw_legacy['disable_welcome_wizard'] ?? null, + 'Mapped key must persist physically in the legacy row (downgrade safety).' ); } } diff --git a/tests/php/src/Admin/Settings/Migration/LegacyMirrorTest.php b/tests/php/src/Admin/Settings/Migration/LegacyMirrorTest.php new file mode 100644 index 0000000000..990f0275ad --- /dev/null +++ b/tests/php/src/Admin/Settings/Migration/LegacyMirrorTest.php @@ -0,0 +1,179 @@ +schema_cb = static function (): array { + return [ + [ + 'id' => 'banner_width', + 'type' => 'field', + 'legacy_key' => 'dokan_appearance.store_banner_width', + 'default' => 400, + ], + ]; + }; + add_filter( 'dokan_get_admin_settings_schema', $this->schema_cb ); + + delete_option( 'dokan_admin_settings' ); + delete_option( 'dokan_appearance' ); + delete_option( LegacyMirror::SNAPSHOT_KEY ); + wp_cache_delete( 'dokan_admin_settings', 'options' ); + wp_cache_delete( 'dokan_appearance', 'options' ); + wp_cache_delete( LegacyMirror::SNAPSHOT_KEY, 'options' ); + wp_cache_delete( 'alloptions', 'options' ); + wp_cache_delete( 'notoptions', 'options' ); + + // DB rollback between tests doesn't fire `delete_option_*` hooks, so + // the DI container's shared instances retain stale snapshots/maps. + $container = dokan_get_container(); + $container->get( SettingsRepository::class )->flush_cache(); + $container->get( LegacySettingsRepository::class )->flush_cache( null ); + $container->get( LegacySettingsBridge::class )->flush_cache(); + } + + public function tear_down() { + remove_filter( 'dokan_get_admin_settings_schema', $this->schema_cb ); + delete_option( 'dokan_admin_settings' ); + delete_option( 'dokan_appearance' ); + delete_option( LegacyMirror::SNAPSHOT_KEY ); + parent::tear_down(); + } + + /** + * Physical row content as an old plugin version (no overlay filters) + * would read it. + * + * @return mixed + */ + private function raw_row( string $option_name ) { + global $wpdb; + $raw = $wpdb->get_var( + $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $option_name ) + ); + return is_string( $raw ) ? maybe_unserialize( $raw ) : null; + } + + public function test_flat_option_save_writes_through_to_legacy_row(): void { + dokan_get_container()->get( SettingsRepository::class )->update( [ 'banner_width' => 777 ] ); + + $row = $this->raw_row( 'dokan_appearance' ); + $this->assertIsArray( $row ); + $this->assertSame( 777, $row['store_banner_width'] ); + + // The baseline snapshot is stamped alongside the mirror write. + $snapshot = get_option( LegacyMirror::SNAPSHOT_KEY ); + $this->assertIsArray( $snapshot ); + $this->assertSame( 777, $snapshot['banner_width'] ); + } + + public function test_write_through_is_disabled_by_the_mirror_filter(): void { + add_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); + + dokan_get_container()->get( SettingsRepository::class )->update( [ 'banner_width' => 777 ] ); + + remove_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); + + $this->assertNull( $this->raw_row( 'dokan_appearance' ) ); + $this->assertFalse( get_option( LegacyMirror::SNAPSHOT_KEY ) ); + } + + public function test_first_reconcile_materializes_full_mirror_and_stamps_baseline(): void { + // Flat option populated without firing the repository save path — + // simulates a site that saved via the new UI before the mirror shipped + // (its legacy rows were stripped / never written). + update_option( 'dokan_admin_settings', [ 'banner_width' => 555 ] ); + dokan_get_container()->get( SettingsRepository::class )->flush_cache(); + + dokan_get_container()->get( LegacyMirror::class )->maybe_reconcile(); + + $row = $this->raw_row( 'dokan_appearance' ); + $this->assertIsArray( $row ); + $this->assertSame( 555, $row['store_banner_width'] ); + + $snapshot = get_option( LegacyMirror::SNAPSHOT_KEY ); + $this->assertIsArray( $snapshot ); + $this->assertSame( 555, $snapshot['banner_width'] ); + } + + public function test_downgrade_edit_wins_over_stale_flat_option_on_reconcile(): void { + $container = dokan_get_container(); + + // Step 1 — user saves via the new UI on the new version. + $container->get( SettingsRepository::class )->update( [ 'banner_width' => 111 ] ); + $this->assertSame( 111, $this->raw_row( 'dokan_appearance' )['store_banner_width'] ); + + // Steps 2–3 — plugin downgraded; the OLD version knows nothing about + // the bridge and writes the legacy row directly. + update_option( 'dokan_appearance', [ 'store_banner_width' => 222 ] ); + $this->assertSame( 222, $this->raw_row( 'dokan_appearance' )['store_banner_width'] ); + // The flat option still holds the stale step-1 snapshot. + $this->assertSame( 111, get_option( 'dokan_admin_settings' )['banner_width'] ); + + // Step 4 — plugin upgraded back; reconciliation runs on admin_init. + $container->get( LegacyMirror::class )->maybe_reconcile(); + + // Last write wins: the old-version edit is adopted into the flat option. + $this->assertSame( 222, get_option( 'dokan_admin_settings' )['banner_width'] ); + // And every read path agrees. + $container->get( LegacySettingsRepository::class )->flush_cache( null ); + $this->assertSame( 222, dokan_get_option( 'store_banner_width', 'dokan_appearance' ) ); + } + + public function test_reconcile_is_a_noop_when_nothing_diverged(): void { + $container = dokan_get_container(); + $container->get( SettingsRepository::class )->update( [ 'banner_width' => 111 ] ); + + $container->get( LegacyMirror::class )->maybe_reconcile(); + + $this->assertSame( 111, get_option( 'dokan_admin_settings' )['banner_width'] ); + $this->assertSame( 111, $this->raw_row( 'dokan_appearance' )['store_banner_width'] ); + } + + public function test_reconcile_ignores_keys_absent_from_baseline(): void { + $container = dokan_get_container(); + + // Baseline with an empty legacy row: flat option has nothing to mirror. + $container->get( LegacyMirror::class )->maybe_reconcile(); + $this->assertSame( [], get_option( LegacyMirror::SNAPSHOT_KEY ) ); + + // Flat option gains a value through a raw (foreign) write — no mirror, + // baseline still lacks the key. + update_option( 'dokan_admin_settings', [ 'banner_width' => 111 ] ); + // A legacy row appears with a different value (e.g. a newly-mapped + // field whose legacy leaf predates the mapping). + update_option( 'dokan_appearance', [ 'store_banner_width' => 999 ] ); + + $container->get( LegacyMirror::class )->maybe_reconcile(); + + // Keys absent from the baseline are never adopted — the flat option + // (canonical) keeps its value. + $this->assertSame( 111, get_option( 'dokan_admin_settings' )['banner_width'] ); + } +} diff --git a/tests/php/src/Admin/Settings/Migration/LegacySettingsBridgeTest.php b/tests/php/src/Admin/Settings/Migration/LegacySettingsBridgeTest.php index 32af5ff703..1714334487 100644 --- a/tests/php/src/Admin/Settings/Migration/LegacySettingsBridgeTest.php +++ b/tests/php/src/Admin/Settings/Migration/LegacySettingsBridgeTest.php @@ -504,6 +504,11 @@ public function test_bridge_bootstrap_overlay_reentry_guard_prevents_recursion() return $map; }; add_filter( 'dokan_legacy_settings_key_mapping', $map_filter ); + // Disable the downgrade-safe mirror for this test: its write-through + // and baseline stamping perform additional legitimate top-level + // `get_option( 'dokan_general' )` reads after the save, which would + // skew the counter that this test uses to detect overlay REENTRY. + add_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); delete_option( 'dokan_general' ); delete_option( 'dokan_admin_settings' ); @@ -538,6 +543,7 @@ public function test_bridge_bootstrap_overlay_reentry_guard_prevents_recursion() remove_filter( 'option_dokan_general', [ $bootstrap, 'apply_overlay' ], 10 ); remove_filter( 'default_option_dokan_general', [ $bootstrap, 'apply_overlay' ], 10 ); remove_filter( 'dokan_legacy_settings_key_mapping', $map_filter ); + remove_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); delete_option( 'dokan_general' ); delete_option( 'dokan_admin_settings' ); diff --git a/tests/php/src/Admin/Settings/Migration/StripMappedKeysTest.php b/tests/php/src/Admin/Settings/Migration/StripMappedKeysTest.php index 46e0c7797e..b056809a1f 100644 --- a/tests/php/src/Admin/Settings/Migration/StripMappedKeysTest.php +++ b/tests/php/src/Admin/Settings/Migration/StripMappedKeysTest.php @@ -133,7 +133,7 @@ public function test_strip_is_noop_when_section_has_no_mapped_fields(): void { $this->assertSame( $payload, $result ); } - public function test_persist_legacy_section_writes_to_new_and_returns_stripped(): void { + public function test_persist_legacy_section_writes_to_new_and_keeps_mapped_keys_by_default(): void { $cb = static function (): array { return [ [ @@ -146,6 +146,42 @@ public function test_persist_legacy_section_writes_to_new_and_returns_stripped() }; add_filter( 'dokan_get_admin_settings_schema', $cb ); + delete_option( 'dokan_admin_settings' ); + $bridge = new LegacySettingsBridge(); + $persistable = $bridge->persist_legacy_section( + 'dokan_appearance', + [ + 'store_banner_width' => 1280, + 'site_layout' => 'full-width', + ] + ); + + $new_option = get_option( 'dokan_admin_settings', [] ); + + remove_filter( 'dokan_get_admin_settings_schema', $cb ); + delete_option( 'dokan_admin_settings' ); + + $this->assertSame( 1280, $new_option['banner_width'] ); + // Legacy mirror is on by default: the row keeps a physical copy of the + // mapped key so a downgraded plugin version still reads current data. + $this->assertSame( 1280, $persistable['store_banner_width'] ); + $this->assertSame( 'full-width', $persistable['site_layout'] ); + } + + public function test_persist_legacy_section_strips_mapped_keys_when_mirror_disabled(): void { + $cb = static function (): array { + return [ + [ + 'id' => 'banner_width', + 'type' => 'field', + 'legacy_key' => 'dokan_appearance.store_banner_width', + 'default' => 400, + ], + ]; + }; + add_filter( 'dokan_get_admin_settings_schema', $cb ); + add_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); + delete_option( 'dokan_admin_settings' ); $bridge = new LegacySettingsBridge(); $stripped = $bridge->persist_legacy_section( @@ -159,6 +195,7 @@ public function test_persist_legacy_section_writes_to_new_and_returns_stripped() $new_option = get_option( 'dokan_admin_settings', [] ); remove_filter( 'dokan_get_admin_settings_schema', $cb ); + remove_filter( 'dokan_admin_settings_legacy_mirror', '__return_false' ); delete_option( 'dokan_admin_settings' ); $this->assertSame( 1280, $new_option['banner_width'] ); diff --git a/tests/php/src/Admin/Settings/Migration/Transformer/WithdrawChargeTransformerTest.php b/tests/php/src/Admin/Settings/Migration/Transformer/WithdrawChargeTransformerTest.php index 8ca2ef8586..8762a74971 100644 --- a/tests/php/src/Admin/Settings/Migration/Transformer/WithdrawChargeTransformerTest.php +++ b/tests/php/src/Admin/Settings/Migration/Transformer/WithdrawChargeTransformerTest.php @@ -92,6 +92,15 @@ public function test_round_trip_preserves_legacy_shape(): void { $legacy = [ 'fixed' => '1.23', 'percentage' => '4.56' ]; $back = $transformer->to_legacy( $transformer->to_new( $legacy ) ); - $this->assertSame( $legacy, $back ); + // Values round-trip losslessly; key ORDER follows to_legacy()'s + // locked output contract (percentage first — see + // test_to_legacy_flips_keys_and_preserves_values), not the input. + $this->assertSame( + [ + 'percentage' => '4.56', + 'fixed' => '1.23', + ], + $back + ); } }