table(); $lock = new DesignLock(); self::assertTrue($lock->acquire('presets')); self::assertMatchesRegularExpression('/^\d+:[0-9a-f]{8}$/', $db->rows['lit_mcp_lock_presets']); self::assertFalse((new DesignLock())->acquire('presets')); self::assertTrue($lock->acquire('variables'), 'areas lock independently'); $lock->release('presets'); self::assertSame([], array_diff_key($db->rows, ['lit_mcp_lock_variables' => 1])); self::assertTrue((new DesignLock())->acquire('presets')); } /** Audit L5: both areas rewrite the whole et_divi array, so they exclude each other. */ public function testColorsAndThemeOptionsShareOneLock(): void { $this->table(); self::assertSame(DesignLock::option('colors'), DesignLock::option('theme_options')); self::assertTrue((new DesignLock())->acquire('colors')); self::assertFalse((new DesignLock())->acquire('theme_options')); self::assertTrue((new DesignLock())->acquire('variables')); } public function testAStaleLockIsTakenOverExactlyOnce(): void { $db = $this->table(); $db->rows['lit_mcp_lock_presets'] = (time() - DesignLock::STALE_AFTER - 1) . ':old'; self::assertTrue((new DesignLock())->acquire('presets')); self::assertGreaterThan(time() - 5, (int) $db->rows['lit_mcp_lock_presets']); self::assertFalse((new DesignLock())->acquire('presets'), 'the second taker sees a fresh lock'); } /** Audit L6: a holder whose lock went stale and was taken over must not free the new holder's lock. */ public function testReleaseOnlyDeletesTheOwnToken(): void { $db = $this->table(); $old = new DesignLock(); self::assertTrue($old->acquire('presets')); $db->rows['lit_mcp_lock_presets'] = (time() - DesignLock::STALE_AFTER - 1) . ':x'; $new = new DesignLock(); self::assertTrue($new->acquire('presets')); $old->release('presets'); self::assertArrayHasKey('lit_mcp_lock_presets', $db->rows); self::assertFalse((new DesignLock())->acquire('presets')); $new->release('presets'); self::assertArrayNotHasKey('lit_mcp_lock_presets', $db->rows); } public function testAFreshLockIsNotTakenOver(): void { $db = $this->table(); $db->rows['lit_mcp_lock_presets'] = (time() - 10) . ':x'; self::assertFalse((new DesignLock())->acquire('presets')); } public function testOptionNamesAndUnknownAreas(): void { self::assertSame('lit_mcp_lock_divi_options', DesignLock::option('theme_options')); self::assertSame('lit_mcp_lock_presets', DesignLock::option('presets')); $this->expectException(InvalidArgumentException::class); DesignLock::option('pages'); } /** * Audit L5 (re-review): get_option('et_divi') comes from the object cache loaded at the start of the request * (autoload / alloptions). A writer that finished meanwhile is invisible until the lock holder drops that cache. */ public function testTheLockHolderReadsEtDiviFreshNotFromTheRequestStartCache(): void { $db = $this->table(); $stored = ['et_divi' => ['color' => 'alt'], 'et_divi_global_variables' => ['v' => 'alt']]; $cache = []; // Like WordPress: every option is cached under its own key (not autoloaded ones) or in "alloptions". Functions\when('get_option')->alias(static function (string $name) use (&$stored, &$cache) { $key = 'options:' . $name; $cache[$key] ??= $stored[$name] ?? false; return $cache[$key]; }); Functions\when('wp_cache_flush_group')->alias(static function (string $group) use (&$cache): bool { foreach (array_keys($cache) as $key) { if (str_starts_with($key, $group . ':')) { unset($cache[$key]); } } return true; }); self::assertSame(['color' => 'alt'], get_option('et_divi'), 'request B cached the old array'); self::assertSame(['v' => 'alt'], get_option('et_divi_global_variables')); $stored['et_divi'] = ['color' => 'neu']; // request A wrote and released meanwhile $stored['et_divi_global_variables'] = ['v' => 'neu']; self::assertTrue((new DesignLock())->acquire('colors')); self::assertSame(['color' => 'neu'], get_option('et_divi')); self::assertSame(['v' => 'neu'], get_option('et_divi_global_variables'), 'also options that are not autoloaded'); self::assertNotSame([], $db->rows); } }