Skip to content

Fix: Replace symfony/cache with WordPress-native PSR-16 implementation for SSO#357

Open
superdav42 wants to merge 4 commits intomainfrom
fix/sso-psr16-cache-compatibility
Open

Fix: Replace symfony/cache with WordPress-native PSR-16 implementation for SSO#357
superdav42 wants to merge 4 commits intomainfrom
fix/sso-psr16-cache-compatibility

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Mar 5, 2026

Summary

This PR resolves a fatal error in SSO functionality caused by incompatibility between symfony/cache v5.4 and psr/simple-cache v3.0+.

Problem

The SSO feature was experiencing a fatal error:

Fatal Error: Uncaught LogicException: psr/simple-cache 3.0+ is not compatible with this version of symfony/cache. 
Please upgrade symfony/cache to 6.0+ or downgrade psr/simple-cache to 1.x or 2.x.

This occurred because:

  • symfony/cache v5.4.x has a compatibility check that rejects psr/simple-cache 3.0+
  • Upgrading to symfony/cache 6.0+ requires PHP 8.1+ (not compatible with our PHP 7.4 requirement)
  • The jasny/sso library requires PSR-16 CacheInterface

Solution

Created a WordPress-native PSR-16 cache implementation that:

  • ✅ Implements the full Psr\SimpleCache\CacheInterface (PSR-16 standard)
  • ✅ Uses WordPress transients (get_site_transient, set_site_transient, delete_site_transient)
  • ✅ Eliminates the symfony/cache dependency completely
  • ✅ Maintains compatibility with jasny/sso library requirements

Changes

New Files

  • inc/sso/class-wordpress-simple-cache.php - WordPress transient-based PSR-16 implementation
  • tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php - Comprehensive PHPUnit test suite (20+ tests)
  • tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php - Standalone integration tests

Modified Files

  • inc/sso/class-sso.php - Updated to use WordPress_Simple_Cache instead of Psr16Cache
  • composer.json - Removed symfony/cache dependency
  • composer.lock - Updated (removed 3 packages: symfony/cache, symfony/cache-contracts, symfony/var-exporter)

Benefits

  1. Fixes Fatal Error - Resolves the PSR-16 compatibility issue
  2. Reduces Dependencies - Removes 3 Symfony packages (smaller vendor footprint)
  3. Better WordPress Integration - Uses native WordPress APIs instead of Symfony components
  4. Fully Tested - Includes comprehensive unit and integration tests
  5. PSR-16 Compliant - Maintains full compatibility with jasny/sso requirements

Testing

All PSR-16 interface methods tested:

  • get(), set(), delete(), clear(), has()
  • getMultiple(), setMultiple(), deleteMultiple()
  • ✅ TTL handling (integer, null, DateInterval)
  • ✅ Data type storage (string, int, float, bool, array, object)
  • ✅ Cache prefix isolation
  • ✅ Jasny/SSO compatibility

Notes

  • PHPCS warnings about $default parameter name are acceptable (required by PSR-16 standard)
  • Direct database calls in clear() method are necessary for bulk transient deletion
  • PHPStan analysis passes with no errors

Migration

No migration needed - the change is backward compatible. Existing SSO sessions will continue to work as the cache implementation is transparent to the SSO logic.

Fixes: Fatal error "psr/simple-cache 3.0+ is not compatible with symfony/cache v5.4"

Summary by CodeRabbit

  • Refactor

    • Switched the caching backend to a WordPress-native transient-based PSR-16 implementation for tighter platform integration.
  • Chores

    • Removed an external caching dependency from the project configuration.
  • Tests

    • Added comprehensive unit and integration tests covering the new cache implementation and behaviors.
  • Style

    • Normalized a public method signature to include an explicit void return type.

…n for SSO

This commit resolves a fatal error caused by incompatibility between
symfony/cache v5.4 and psr/simple-cache v3.0+.

Changes:
- Add WordPress_Simple_Cache class implementing PSR-16 CacheInterface
  using WordPress transients (get_site_transient, set_site_transient)
- Update SSO class to use WordPress_Simple_Cache instead of symfony/cache
- Remove symfony/cache dependency from composer.json (reduces 3 packages)
- Add comprehensive unit tests for WordPress_Simple_Cache
- Add standalone integration tests for PSR-16 compliance

Benefits:
- Eliminates PSR-16 compatibility error in SSO functionality
- Reduces dependency footprint (removed symfony/cache, symfony/cache-contracts, symfony/var-exporter)
- Better WordPress integration using native transient APIs
- Maintains full PSR-16 compatibility required by jasny/sso library

Fixes: Fatal Error 'psr/simple-cache 3.0+ is not compatible with symfony/cache v5.4'

Note: PHPCS warnings about 'default' parameter name and direct database
calls are acceptable as they follow PSR-16 standard and are necessary
for cache clearing functionality.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

Removed the symfony/cache dependency and replaced the Symfony-based cache initialization with a new WordPress-backed PSR-16 Simple Cache used by the SSO class; added the cache implementation and unit/integration tests; added an explicit void return type to toggle_maintenance_mode().

Changes

Cohort / File(s) Summary
Dependency manifest
composer.json
Removed symfony/cache from require.
SSO integration
inc/sso/class-sso.php
Replaced PSR-6/PSR-16 FilesystemAdapter/Psr16Cache initialization with a WordPress_Simple_Cache instance; updated cache property docblock to \Psr\SimpleCache\CacheInterface.
WordPress PSR-16 implementation
inc/sso/class-wordpress-simple-cache.php
Added WordPress_Simple_Cache implementing Psr\SimpleCache\CacheInterface: transient-backed storage with prefixing, PSR-16 key validation, TTL conversion, bulk ops, and clear/delete semantics.
Tests — unit & integration
tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php, tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php
Added PHPUnit test class and a standalone CLI integration test covering PSR-16 conformance, get/set/delete/has, bulk ops, TTLs, data types, and prefix isolation.
Minor API signature change
inc/class-maintenance-mode.php
Added : void return type to toggle_maintenance_mode() and updated its docblock.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I dug a new cache hole today,

Transients tucked my keys away,
PSR-16 snug in WordPress ground,
Tests hopped round and checked each bound,
A rabbit's cheer — the cache is sound! 🎉

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately describes the main change: replacing symfony/cache with a WordPress-native PSR-16 implementation for SSO, which directly addresses the core objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/sso-psr16-cache-compatibility

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
composer.json (1)

32-68: ⚠️ Potential issue | 🔴 Critical

Re-add hashids/hashids before merge.

inc/helpers/class-hash.php imports and instantiates Hashids\Hashids on lines 45 and 61, but the dependency is missing from composer.json require. Production will fail with a fatal class-not-found error when Hash::encode() or Hash::decode() are called.

Suggested fix
     "require": {
         "php": ">=7.4.1",
+        "hashids/hashids": "^4.1.0",
         "automattic/jetpack-autoloader": "^5.0.0",
         "wp-ultimo/autoloader-plugin": "dev-main",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composer.json` around lines 32 - 68, The composer.json is missing the
hashids/hashids package used by inc/helpers/class-hash.php (it instantiates
Hashids\Hashids and calls Hash::encode() and Hash::decode()); add the
hashids/hashids requirement to composer.json's "require" (pick a compatible
semver such as ^4.0) and run composer update/install so the Hashids\Hashids
class is available at runtime.
🧹 Nitpick comments (2)
tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php (1)

216-230: TTL tests don’t validate expiration behavior.

Current assertions only check “set + immediate get.” They won’t catch TTL conversion bugs (especially DateInterval). Add assertions against timeout metadata or time-controlled expiry behavior.

Also applies to: 250-261

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php` around lines 216 - 230,
The TTL tests (e.g., test_ttl_with_integer_seconds) only assert set+get and miss
verifying the actual expiration metadata, so update these tests to assert the
stored transient timeout matches the expected expiry and/or simulate time
progression to confirm expiration; specifically, after calling
$this->cache->set($key, $value, $ttl) assert that the underlying timeout option
(the _transient_timeout_{key} option or equivalent returned by
wp_get_transient_timeout / get_option) equals time() + $ttl (or within a
1-second tolerance), and add a complementary assertion that the cached value is
no longer returned after advancing simulated time or by manually setting the
timeout to a past timestamp and calling $this->cache->get($key) to ensure
expiration behavior is enforced (apply the same changes to the other TTL test
referenced around lines 250-261).
tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php (1)

87-93: TTL integration tests are non-functional with current mock storage.

$expiration is ignored in set_site_transient() mock, so TTL assertions can’t validate expiry behavior.

Also applies to: 304-320

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php` around lines
87 - 93, The mock set_site_transient currently ignores $expiration so TTL tests
fail; update set_site_transient to store both the value and an expiry timestamp
in the global $_site_transients (e.g. store an array ['value'=>...,
'expires_at'=> time() + $expiration] or null for no TTL) and update the
corresponding get_site_transient (and any delete_site_transient logic) to check
$_site_transients[$key]['expires_at'] and treat expired entries as missing
(remove them and return false/null) so TTL assertions work; change references to
$_site_transients in both set_site_transient and get_site_transient to the new
shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/sso/class-wordpress-simple-cache.php`:
- Around line 83-92: The clear() method currently returns true unconditionally;
change it so it checks the result of $wpdb->query and returns failure when the
DB operation errored: capture the query call (the DELETE using $wpdb->prepare
and $wpdb->esc_like) then if $wpdb->last_error is non-empty return false,
otherwise return true (this ensures clear() reports failure on DB errors while
still treating zero affected rows as success).
- Around line 169-173: The DateInterval handling mutates $now because
DateTime::add() returns the same instance, so $future and $now end up identical
and the TTL becomes 0; fix this in class-wordpress-simple-cache.php by creating
a separate DateTime instance (e.g., clone $now or instantiate a new DateTime)
before calling add() so $future != $now, then compute and return
$future->getTimestamp() - $now->getTimestamp(); ensure this change is applied to
the code handling $ttl when it's an instance of \DateInterval (references: $ttl,
$now, $future, DateTime::add).
- Around line 46-49: The get() and has() methods currently treat WordPress
transient return value false as a cache miss, so legitimately cached boolean
false cannot be stored; update the caching layer (methods get(), has(), and the
corresponding set()/set_site_transient usage) to wrap stored values in a
sentinel structure (e.g., ['hit' => true, 'value' => ...]) before calling
set_site_transient, and on retrieval in get() unwrap that structure to return
the original value (including false) while has() should check for the wrapper
and return true only when the wrapper is present; ensure existing consumers of
get() keep receiving the raw unwrapped value and that miss handling still
returns the $default.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php`:
- Around line 325-331: The test runner fails because the included class file
exits when ABSPATH is undefined and the cache clear() method expects a global
$wpdb that the test doesn't provide; before requiring
'inc/sso/class-wordpress-simple-cache.php' (in
WordPress_Simple_Cache_Integration_Test runner) define a minimal ABSPATH
constant and inject a mocked global $wpdb with stubbed methods query(),
prepare(), and esc_like() (and any used properties) so clear() can run; also
ensure mock_wordpress_functions() remains in place and is called before
inclusion so get_site_transient()/set_site_transient()/delete_site_transient()
and the $wpdb mock exist when the class file is loaded and when clear() is
invoked during tests.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php`:
- Around line 332-335: The local variable $user_id is declared but never used;
remove the unused declaration (the line setting $user_id = 42) from the test to
avoid dead code, or if the test should assert on the user id, instead use
$user_id in the relevant assertion or call; locate the declaration near
$broker_id and $session_id in the test and either delete the $user_id line or
wire it into the test expectations where appropriate.

---

Outside diff comments:
In `@composer.json`:
- Around line 32-68: The composer.json is missing the hashids/hashids package
used by inc/helpers/class-hash.php (it instantiates Hashids\Hashids and calls
Hash::encode() and Hash::decode()); add the hashids/hashids requirement to
composer.json's "require" (pick a compatible semver such as ^4.0) and run
composer update/install so the Hashids\Hashids class is available at runtime.

---

Nitpick comments:
In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php`:
- Around line 87-93: The mock set_site_transient currently ignores $expiration
so TTL tests fail; update set_site_transient to store both the value and an
expiry timestamp in the global $_site_transients (e.g. store an array
['value'=>..., 'expires_at'=> time() + $expiration] or null for no TTL) and
update the corresponding get_site_transient (and any delete_site_transient
logic) to check $_site_transients[$key]['expires_at'] and treat expired entries
as missing (remove them and return false/null) so TTL assertions work; change
references to $_site_transients in both set_site_transient and
get_site_transient to the new shape.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php`:
- Around line 216-230: The TTL tests (e.g., test_ttl_with_integer_seconds) only
assert set+get and miss verifying the actual expiration metadata, so update
these tests to assert the stored transient timeout matches the expected expiry
and/or simulate time progression to confirm expiration; specifically, after
calling $this->cache->set($key, $value, $ttl) assert that the underlying timeout
option (the _transient_timeout_{key} option or equivalent returned by
wp_get_transient_timeout / get_option) equals time() + $ttl (or within a
1-second tolerance), and add a complementary assertion that the cached value is
no longer returned after advancing simulated time or by manually setting the
timeout to a past timestamp and calling $this->cache->get($key) to ensure
expiration behavior is enforced (apply the same changes to the other TTL test
referenced around lines 250-261).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92669a09-ba40-48cd-8dd8-3544b7a26bbc

📥 Commits

Reviewing files that changed from the base of the PR and between fa58d9d and 34cab1e.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • composer.json
  • inc/sso/class-sso.php
  • inc/sso/class-wordpress-simple-cache.php
  • tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php
  • tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php

Comment on lines +83 to +92
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s",
$wpdb->esc_like('_site_transient_' . $this->prefix) . '%',
$wpdb->esc_like('_site_transient_timeout_' . $this->prefix) . '%'
)
);

return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

clear() always returns success even on DB failure.

Line 91 returns true unconditionally. If $wpdb->query() fails, callers won’t know clear failed.

Proposed fix
-		$wpdb->query(
+		$result = $wpdb->query(
 			$wpdb->prepare(
 				"DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s",
 				$wpdb->esc_like('_site_transient_' . $this->prefix) . '%',
 				$wpdb->esc_like('_site_transient_timeout_' . $this->prefix) . '%'
 			)
 		);
 
-		return true;
+		return false !== $result;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/sso/class-wordpress-simple-cache.php` around lines 83 - 92, The clear()
method currently returns true unconditionally; change it so it checks the result
of $wpdb->query and returns failure when the DB operation errored: capture the
query call (the DELETE using $wpdb->prepare and $wpdb->esc_like) then if
$wpdb->last_error is non-empty return false, otherwise return true (this ensures
clear() reports failure on DB errors while still treating zero affected rows as
success).

Comment on lines +169 to +173
if ($ttl instanceof \DateInterval) {
$now = new \DateTime();
$future = $now->add($ttl);
return $future->getTimestamp() - $now->getTimestamp();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

PHP DateTime::add() mutate object return same instance

💡 Result:

Yes. DateTime::add() mutates the existing DateTime object and returns the same instance (so assignments just create another reference to the same object), mainly to allow method chaining. To avoid mutation, use DateTimeImmutable::add() (returns a new object) or clone before adding. [1], [2]

$dt = new DateTime('2026-03-05');
$dt2 = $dt->add(new DateInterval('P1D'));

var_dump($dt === $dt2); // true  (same instance)
$dt = new DateTime('2026-03-05');
$dt2 = (clone $dt)->add(new DateInterval('P1D')); // $dt unchanged
$dt = new DateTimeImmutable('2026-03-05');
$dt2 = $dt->add(new DateInterval('P1D'));        // new instance

Sources: PHP manual for DateTime::add() and DateTime mutability, plus DateTimeImmutable::add() behavior. [1], [2]

[1] https://www.php.net/manual/en/datetime.add.php
[2] https://www.php.net/manual/en/class.datetime.php


🏁 Script executed:

# Check the actual file and surrounding context
find . -name "class-wordpress-simple-cache.php" -path "*/inc/sso/*" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 123


🏁 Script executed:

# Read the file to see the complete context around lines 169-173
cat -n inc/sso/class-wordpress-simple-cache.php | sed -n '160,185p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 611


DateInterval TTL currently collapses to 0 (no expiration).

On line 171, DateTime::add() mutates $now and returns the same instance, so $future references the identical object. Line 172 then subtracts identical timestamps, yielding 0. This causes DateInterval entries to become non-expiring.

Proposed fix
 		if ($ttl instanceof \DateInterval) {
-			$now    = new \DateTime();
-			$future = $now->add($ttl);
-			return $future->getTimestamp() - $now->getTimestamp();
+			$now    = new \DateTimeImmutable();
+			$future = $now->add($ttl);
+			return max(0, $future->getTimestamp() - $now->getTimestamp());
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($ttl instanceof \DateInterval) {
$now = new \DateTime();
$future = $now->add($ttl);
return $future->getTimestamp() - $now->getTimestamp();
}
if ($ttl instanceof \DateInterval) {
$now = new \DateTimeImmutable();
$future = $now->add($ttl);
return max(0, $future->getTimestamp() - $now->getTimestamp());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/sso/class-wordpress-simple-cache.php` around lines 169 - 173, The
DateInterval handling mutates $now because DateTime::add() returns the same
instance, so $future and $now end up identical and the TTL becomes 0; fix this
in class-wordpress-simple-cache.php by creating a separate DateTime instance
(e.g., clone $now or instantiate a new DateTime) before calling add() so $future
!= $now, then compute and return $future->getTimestamp() - $now->getTimestamp();
ensure this change is applied to the code handling $ttl when it's an instance of
\DateInterval (references: $ttl, $now, $future, DateTime::add).

Comment on lines +325 to +331
// Auto-run if this file is executed directly (not included by PHPUnit).
if (php_sapi_name() === 'cli' && !defined('PHPUNIT_COMPOSER_INSTALL')) {
// Load autoloader.
require_once dirname(dirname(dirname(__DIR__))) . '/vendor/autoload.php';
require_once dirname(dirname(dirname(__DIR__))) . '/inc/sso/class-wordpress-simple-cache.php';

WordPress_Simple_Cache_Integration_Test::run_all_tests();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify ABSPATH guard and clear() DB dependency in cache class
rg -n "defined\\('ABSPATH'\\) \\|\\| exit|function clear\\(|global \\$wpdb" inc/sso/class-wordpress-simple-cache.php

# Verify standalone runner path and clear() usage in integration test
rg -n "require_once .*class-wordpress-simple-cache.php|run_all_tests|->clear\\(" tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1013


🏁 Script executed:

#!/bin/bash
# Check ABSPATH guard in cache class
echo "=== Checking for ABSPATH guard in class file ==="
rg "ABSPATH" inc/sso/class-wordpress-simple-cache.php -A 2 -B 2

echo ""
echo "=== Checking clear() function definition ==="
rg "function clear" inc/sso/class-wordpress-simple-cache.php -A 10

echo ""
echo "=== Checking for $wpdb usage in class file ==="
rg "wpdb" inc/sso/class-wordpress-simple-cache.php -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 916


🏁 Script executed:

#!/bin/bash
# Check for setUp, bootstrap, or $wpdb mocking in the test file
echo "=== Checking for setUp/init methods ==="
rg "function setUp|function __construct|global \\\$wpdb|\\$GLOBALS\[|mock|Mock" tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php -n

echo ""
echo "=== Checking file header and class definition ==="
head -50 tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1451


🏁 Script executed:

#!/bin/bash
# Find and display the mock_wordpress_functions method
rg -n "mock_wordpress_functions" tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php -A 30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2030


🏁 Script executed:

#!/bin/bash
# Get the complete mock_wordpress_functions method
rg -n "private static function mock_wordpress_functions" tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php -A 60

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1846


Standalone CLI runner cannot execute due to unmet WordPress dependencies.

The included cache class file exits when ABSPATH is undefined (line 2: defined('ABSPATH') || exit;), and the clear() method requires the global $wpdb object which is not mocked in the test setup. The mock_wordpress_functions() method only provides get_site_transient, set_site_transient, and delete_site_transient—it does not mock $wpdb. Since clear() is called 21 times throughout the test suite and directly executes database queries via $wpdb->query(), $wpdb->prepare(), and $wpdb->esc_like(), the standalone runner will fail immediately upon inclusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php` around lines
325 - 331, The test runner fails because the included class file exits when
ABSPATH is undefined and the cache clear() method expects a global $wpdb that
the test doesn't provide; before requiring
'inc/sso/class-wordpress-simple-cache.php' (in
WordPress_Simple_Cache_Integration_Test runner) define a minimal ABSPATH
constant and inject a mocked global $wpdb with stubbed methods query(),
prepare(), and esc_like() (and any used properties) so clear() can run; also
ensure mock_wordpress_functions() remains in place and is called before
inclusion so get_site_transient()/set_site_transient()/delete_site_transient()
and the $wpdb mock exist when the class file is loaded and when clear() is
invoked during tests.

Comment on lines +332 to +335
$broker_id = 'test_broker_123';
$session_id = 'session_abc_456';
$user_id = 42;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused local $user_id.

$user_id is declared but never used in this test.

🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 334-334: Avoid unused local variables such as '$user_id'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Test.php` around lines 332 - 335,
The local variable $user_id is declared but never used; remove the unused
declaration (the line setting $user_id = 42) from the test to avoid dead code,
or if the test should assert on the user id, instead use $user_id in the
relevant assertion or call; locate the declaration near $broker_id and
$session_id in the test and either delete the $user_id line or wire it into the
test expectations where appropriate.

…e implementation bugs

- Add missing patches/mpdf-psr-http-message-shim-php8-compat.patch (file was
  referenced in composer.json but not committed, causing composer install to fail
  on all PHP versions with a TypeError in cweagans/composer-patches)
- Restore hashids/hashids to composer.json (was removed but still required by
  inc/helpers/class-hash.php — would cause fatal class-not-found in production)
- Fix WordPress_Simple_Cache::get/set to wrap values in array so that storing
  false can be distinguished from a cache miss (get_site_transient returns false
  for both missing keys and stored false values)
- Fix WordPress_Simple_Cache::clear() to use delete_site_transient() which
  properly clears the WordPress object cache, not just the DB rows (direct DB
  delete left stale values in the in-memory cache, causing test failures)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
inc/sso/class-wordpress-simple-cache.php (1)

181-184: ⚠️ Potential issue | 🔴 Critical

Clone $now before adding the interval.

DateTime::add() mutates the original object, so $future and $now point at the same timestamp here and the computed TTL collapses to 0.

Proposed fix
 		if ($ttl instanceof \DateInterval) {
-			$now    = new \DateTime();
+			$now    = new \DateTimeImmutable();
 			$future = $now->add($ttl);
-			return $future->getTimestamp() - $now->getTimestamp();
+			return max(0, $future->getTimestamp() - $now->getTimestamp());
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/sso/class-wordpress-simple-cache.php` around lines 181 - 184, The code
incorrectly mutates $now by calling DateTime::add(), causing $future and $now to
be identical and the TTL to become 0; fix it by cloning $now before applying the
DateInterval ($ttl) so you compute the difference between the original timestamp
and the advanced timestamp (use a clone of the DateTime instance instead of
mutating $now), updating the block that handles DateInterval to create a
separate $future from $now and then return $future->getTimestamp() -
$now->getTimestamp().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/sso/class-wordpress-simple-cache.php`:
- Around line 46-67: Add PSR-16 validation: implement a private helper (e.g.,
validateKey(string $key): void) that throws
Psr\SimpleCache\InvalidArgumentException when a key is empty, >64 chars, or
contains any of the reserved characters {}()/\@:, and another helper (e.g.,
validateIterable($iterable): void) that throws the same exception when the
argument is not an array or Traversable; call validateKey at the start of get(),
set(), delete(), has(), and any other single-key methods (reference methods get
and set in class WordPress_Simple_Cache) and call validateIterable plus
validateKey for each item in getMultiple(), setMultiple(), and deleteMultiple()
before processing so invalid keys produce the required exception rather than
being passed to get_site_transient()/set_site_transient() or used in foreach
loops.
- Around line 84-102: The clear() method is ineffective with Redis/Memcached
because it only scans the sitemeta table; instead implement versioned namespace
invalidation: add a cache namespace/version property (e.g., $this->prefixVersion
stored in an option like "{$this->prefix}_version"), change all transient keys
(in the class methods that set/get/delete transients) to include that version
(e.g., "{$this->prefix}_v{$version}_{$name}"), and then make clear() simply
increment/update the stored version option (or regenerate the prefix) so
existing cached keys become unreachable without DB enumeration; update
references to delete_site_transient only for explicit deletes, not for global
clear().
- Around line 64-67: The set() method currently passes a 0 TTL into
set_site_transient which WordPress treats as permanent; change set() so after
calling convert_ttl_to_seconds($ttl) you detect non-positive values and replace
them with a minimal positive expiry (e.g. 1 second) so non-positive TTLs are
treated as expired rather than permanent; note setMultiple() delegates to set(),
so this single guard in set() fixes both.

---

Duplicate comments:
In `@inc/sso/class-wordpress-simple-cache.php`:
- Around line 181-184: The code incorrectly mutates $now by calling
DateTime::add(), causing $future and $now to be identical and the TTL to become
0; fix it by cloning $now before applying the DateInterval ($ttl) so you compute
the difference between the original timestamp and the advanced timestamp (use a
clone of the DateTime instance instead of mutating $now), updating the block
that handles DateInterval to create a separate $future from $now and then return
$future->getTimestamp() - $now->getTimestamp().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 538c7686-107b-45b6-b224-f1f225ca7cf0

📥 Commits

Reviewing files that changed from the base of the PR and between 34cab1e and 0926af0.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • composer.json
  • inc/sso/class-wordpress-simple-cache.php
  • patches/mpdf-psr-http-message-shim-php8-compat.patch
🚧 Files skipped from review as they are similar to previous changes (1)
  • composer.json

Comment on lines +84 to +102
public function clear() {
global $wpdb;

// Get all transient keys with our prefix to delete them properly (handles object cache).
$like_pattern = $wpdb->esc_like('_site_transient_' . $this->prefix) . '%';
$meta_keys = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s",
$like_pattern
)
);

foreach ($meta_keys as $meta_key) {
// Strip the '_site_transient_' prefix to get the original transient name.
$transient_name = substr($meta_key, strlen('_site_transient_'));
delete_site_transient($transient_name);
}

return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "\.(php|md)$" | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 699


🏁 Script executed:

find . -name "class-wordpress-simple-cache.php" -o -name "class-sso.php" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 182


🏁 Script executed:

cat -n inc/sso/class-wordpress-simple-cache.php | head -120

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4399


🏁 Script executed:

sed -n '980,991p' inc/sso/class-sso.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 380


🏁 Script executed:

grep -r "set_site_transient\|get_site_transient\|delete_site_transient" inc/sso/ --include="*.php"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 876


🏁 Script executed:

sed -n '1,100p' inc/sso/class-sso.php | grep -A 20 "cache()"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🌐 Web query:

WordPress set_site_transient behavior with Redis Memcached external object cache

💡 Result:

What set_site_transient() does with/without a persistent object cache (Redis/Memcached)

1) With an external/persistent object cache enabled (Redis, Memcached via object-cache.php)

  • set_site_transient( $key, $value, $expiration ) stores the value in the object cache, not in the DB: it calls wp_cache_set( $key, $value, 'site-transient', $expiration ). The cache group used is site-transient. [1]
  • The $expiration is passed to the object-cache backend as the cache TTL (seconds). [1]
  • Practical implication: there will be no _site_transient_* / _site_transient_timeout_* rows created/updated in the options/sitemeta tables by core when an external object cache is in use. [1], [2]

2) Without an external object cache (default WP behavior)

  • WordPress persists site transients in the database as network options:
    • _site_transient_{key} = value
    • _site_transient_timeout_{key} = time() + $expiration (only if $expiration is non-zero) [1]
  • (So: DB cleanup/expiration is driven by that stored timeout, and the transient is “found” by reading those options.) [1], [3]

Important behavioral differences you’ll notice with Redis/Memcached

  • Transients become “cache”, not “storage”. With a persistent object cache present, the Transients API no longer uses the Options API tables for persistence and instead relies on the object cache layer. [3]
  • Eviction can happen before your TTL. Especially with Memcached (and sometimes Redis depending on policy/config), entries can disappear due to memory pressure/eviction; TTL is not a guarantee of persistence. (VIP’s Memcached notes: items expire and can also be evicted via LRU; “no expiration” items can still be evicted.) [2]
  • Updating without an expiration can change semantics. Core documents that when not using an external object cache, calling set_transient() again without $expiration keeps the existing expiration; this guarantee does not apply with an external object cache (because the object cache is the source of truth). In practice, you should always pass an explicit expiration if you care about it. [4]

Operational gotchas (common with Redis/Memcached object-cache plugins)

  • Don’t query the DB for transients (or rely on tools that do) when a persistent object cache is enabled—those transients may not be in the DB at all. [2], [3]
  • Flushing Redis/Memcached flushes your transients (and can cause stampedes if many requests regenerate them at once). Treat transients as rebuildable.

Sources

[1] WordPress Developer Reference: set_site_transient() (shows wp_cache_set(..., 'site-transient', $expiration) when wp_using_ext_object_cache())
[2] WordPress VIP docs: Object cache behavior, transients stored in object cache; eviction/LRU/no-expiration notes
[3] WordPress Developer Blog: Transients API uses object cache instead of DB when persistent cache is present
[4] WordPress Developer Reference: set_transient() note about expiration retention only when not using external object cache


🏁 Script executed:

grep -r "wp_cache_flush\|flush\|clear" inc/sso/ --include="*.php" | grep -v "^\s*//"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 313


🏁 Script executed:

rg "class WordPress_Simple_Cache" -A 200 inc/sso/class-wordpress-simple-cache.php | grep -E "(public function|private function)" | head -15

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 643


clear() becomes a no-op when using Redis/Memcached-backed object caching.

This implementation only discovers transients persisted to $wpdb->sitemeta. When Redis/Memcached is enabled, set_site_transient() stores values exclusively in the object cache, never creating database rows. As a result, clear() finds no entries and leaves all SSO state intact in the cache. A safer approach is to implement cache invalidation via a versioned namespace/prefix that doesn't depend on database enumeration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/sso/class-wordpress-simple-cache.php` around lines 84 - 102, The clear()
method is ineffective with Redis/Memcached because it only scans the sitemeta
table; instead implement versioned namespace invalidation: add a cache
namespace/version property (e.g., $this->prefixVersion stored in an option like
"{$this->prefix}_version"), change all transient keys (in the class methods that
set/get/delete transients) to include that version (e.g.,
"{$this->prefix}_v{$version}_{$name}"), and then make clear() simply
increment/update the stored version option (or regenerate the prefix) so
existing cached keys become unreachable without DB enumeration; update
references to delete_site_transient only for explicit deletes, not for global
clear().

Merge changes from main (PRs #352 mpdf PHP8.5 compat, #355 lazy settings,
#358 remove hashids dependency) and apply follow-up improvements:

- Use main's mpdf-psr-http-message-shim-php8-compat.patch (proper PHP 8.5 type
  declarations for Request.php methods)
- Fix TTL=0 handling: non-null TTL <= 0 now stores with 1s expiry (PSR-16)
- Add PSR-16 key validation via validate_key() and validate_iterable()
- Fix PHPStan: toggle_maintenance_mode() return type void

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 7, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

- Add docblocks to mock WordPress functions
- Fix Yoda condition violations throughout assert calls
- Suppress emoji output escaping warnings with phpcs:ignore
- Suppress unused \$expiration parameter warning in mock set_site_transient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 7, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php (1)

77-132: ⚠️ Potential issue | 🟠 Major

Standalone CLI mode still doesn't bootstrap enough WordPress state.

The direct-run path loads inc/sso/class-wordpress-simple-cache.php before any WordPress bootstrap, while this file only mocks the transient functions. Because the cache class is WordPress-guarded on load and clear() still depends on the global $wpdb, php tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php will exit or fatal before the suite finishes. Please define a minimal ABSPATH and $wpdb stub before requiring the cache class, or route this runner through the existing WordPress test bootstrap instead.

Also applies to: 350-356

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php` around lines
77 - 132, The test runner does not define minimal WordPress state so requiring
inc/sso/class-wordpress-simple-cache.php can fatal because the cache class
(WordPress_Simple_Cache) and its clear() method expect ABSPATH and the global
$wpdb; update WordPress_Simple_Cache_Integration_Test::mock_wordpress_functions
(or the test setup) to define('ABSPATH', __DIR__ . '/..') if not defined and
create a minimal $wpdb stub global (an object with the methods/properties used
by WordPress_Simple_Cache::clear) before including the cache class, or instead
change the test to bootstrap via the existing WordPress test bootstrap; ensure
you reference ABSPATH, global $wpdb, and the class
WordPress_Simple_Cache/clear() when making the change.
🧹 Nitpick comments (1)
tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php (1)

292-295: Add a regression check for cached false.

This block covers true, but not false—the exact value WordPress transients also use for cache misses. Since WordPress_Simple_Cache::get() only distinguishes a stored false via the wrapped payload, skipping that case leaves the key regression in this PR unguarded here.

➕ Suggested test addition
 		// Boolean.
 		$cache->set('bool', true);
 		self::assert(true === $cache->get('bool'), 'Stores and retrieves boolean');
+		$cache->set('bool_false', false);
+		self::assert(false === $cache->get('bool_false'), 'Stores and retrieves false without treating it as a miss');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php` around lines
292 - 295, Add a regression test that stores and retrieves a cached false value
to ensure WordPress_Simple_Cache::get() distinguishes stored false from a cache
miss: after the existing true check in WordPress_Simple_Cache_Integration_Test,
call $cache->set('bool_false', false) and assert that $cache->get('bool_false')
=== false using a strict comparison so the wrapped payload path for stored false
is exercised and protected against regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php`:
- Around line 77-132: The test runner does not define minimal WordPress state so
requiring inc/sso/class-wordpress-simple-cache.php can fatal because the cache
class (WordPress_Simple_Cache) and its clear() method expect ABSPATH and the
global $wpdb; update
WordPress_Simple_Cache_Integration_Test::mock_wordpress_functions (or the test
setup) to define('ABSPATH', __DIR__ . '/..') if not defined and create a minimal
$wpdb stub global (an object with the methods/properties used by
WordPress_Simple_Cache::clear) before including the cache class, or instead
change the test to bootstrap via the existing WordPress test bootstrap; ensure
you reference ABSPATH, global $wpdb, and the class
WordPress_Simple_Cache/clear() when making the change.

---

Nitpick comments:
In `@tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php`:
- Around line 292-295: Add a regression test that stores and retrieves a cached
false value to ensure WordPress_Simple_Cache::get() distinguishes stored false
from a cache miss: after the existing true check in
WordPress_Simple_Cache_Integration_Test, call $cache->set('bool_false', false)
and assert that $cache->get('bool_false') === false using a strict comparison so
the wrapped payload path for stored false is exercised and protected against
regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eabaf433-4cff-4cbe-9251-d5245fd1ae81

📥 Commits

Reviewing files that changed from the base of the PR and between 77841c0 and 372f7c2.

📒 Files selected for processing (1)
  • tests/WP_Ultimo/SSO/WordPress_Simple_Cache_Integration_Test.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant