Skip to content

Conversation

@mambax7
Copy link
Collaborator

@mambax7 mambax7 commented Jan 1, 2026

Summary by CodeRabbit

  • Refactor

    • Added multi-engine cache support with per-engine selection, initialization, and centralized configuration handling.
    • Introduced a formal cache engine interface and stricter enforcement of engine capabilities.
    • Normalized public cache APIs and return semantics for more predictable behavior and safer engine orchestration.
    • Hardened initialization, resolution, warnings and garbage-collection flow for more reliable cache operations.
  • Bug Fixes

    • Cache write/delete now consistently return explicit booleans for consistent behavior.

…instead

fix offset is deprecated, use an empty string instead
replacing extract()
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses PHP deprecation warnings related to using null as an array offset by replacing extract() calls with explicit variable extraction and adding validation checks before using variables as array keys.

Key changes:

  • Replaced extract() calls in config() and write() methods with explicit array key checking and assignment
  • Added string type and empty string validation before using variables as array offsets
  • Updated parameter documentation to reflect that null values are accepted

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

htdocs/class/cache/xoopscache.php:435

  • Potential null array offset issue: When $_this->name is null (which is its initial value), accessing $_this->configs[$_this->name] will use null as an array key. This should be guarded with a null check on $_this->name before attempting array access, similar to the pattern used in the updated config method at lines 156-161.
        if (!$engine && isset($_this->configs[$_this->name]['engine'])) {
            $engine = $_this->configs[$_this->name]['engine'];
        }

htdocs/class/cache/xoopscache.php:451

  • Potential null array offset issue: When $_this->name is null (which is its initial value), accessing $_this->configs[$_this->name] will use null as an array key. This should be guarded with a null check on $_this->name before attempting array access, similar to the pattern used in the updated config method at lines 156-161.
        if (!$engine && isset($_this->configs[$_this->name]['engine'])) {
            $engine = $_this->configs[$_this->name]['engine'];

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 99 to 100


Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

Excessive blank lines: There are three consecutive blank lines (lines 99-100) which is inconsistent with the rest of the codebase that typically uses single blank lines between methods.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +336 to 339
$config = $_this->resolveEngineConfig($config);
if ($config === false) {
return false;
}
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

Inconsistent return type behavior: The method returns false when config resolution fails (line 338), but the PHPDoc at line 328 states the return type is mixed (cached data or false). While this is technically correct, it creates an ambiguity where false could mean either "config failed" or "cached data is false". Consider documenting this nuance or using a different sentinel value for configuration failures.

Copilot uses AI. Check for mistakes.
}

$engine = $config['engine'];

Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

Missing null check: The code assumes $_this->engine[$engine] exists and is not null before calling gc() on it. If the engine initialization failed (line 224 sets it to null), this will cause a fatal error. An isInitialized check should be added before accessing the engine, similar to the pattern used in write, read, delete, and clear methods.

Suggested change
if (!isset($_this->engine[$engine]) || $_this->engine[$engine] === null) {
return false;
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

* @var array<string, XoopsCacheEngine|null>
* @access protected
*/
protected $engine = [];
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The $engine property is now initialized as an empty array, but throughout the codebase it's used with string keys (e.g., $_this->engine[$name]). Consider adding a check in the engine() method to ensure that $_this->engine[$name] is not just isset() but also an instance of the expected cache engine class before calling methods on it, to prevent potential type errors if the array is manipulated incorrectly elsewhere.

Copilot uses AI. Check for mistakes.
Comment on lines 160 to 164
$config = $name;

if (isset($config['name']) && is_string($config['name'])) {
$name = $config['name'];
}
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

When $name is an array, it's assigned to $config, but if $config['name'] is not set or not a string, $name remains an array. This could lead to type errors in subsequent code that expects $name to be a string. Consider setting a default string value (e.g., 'default') if the name cannot be extracted from the config array.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

htdocs/class/cache/xoopscache.php:346

  • trigger_error('Cache write not initialized: ' . $engine) omits the error type, so it defaults to E_USER_NOTICE. Elsewhere in this class non-fatal cache failures use E_USER_WARNING; consider passing E_USER_WARNING here as well for consistent severity/visibility.
        if (!$_this->isInitialized($engine)) {
            trigger_error('Cache write not initialized: ' . $engine);

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces single-engine cache storage with a named engine map, adds XoopsCacheEngineInterface and engine loader/resolution helpers, and refactors public API (config/engine/read/write/delete/clear/gc/isInitialized/settings) to normalize inputs, initialize engines, and standardize return types.

Changes

Cohort / File(s) Summary
Cache core
htdocs/class/cache/xoopscache.php
Replaced single engine property with protected $engine = []; added XoopsCacheEngineInterface and updated XoopsCacheEngine; added helpers resolveEngineConfig(), resolveEngineName(), loadEngine(); refactored config(), engine(), gc(), write(), read(), delete(), clear(), isInitialized(), and settings() to validate/normalize inputs, initialize engines, and standardize return types.
APC engine
htdocs/class/cache/apc.php
Normalized return types: write() and delete() now return boolean (cast from apc_store/apc_delete).

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant XoopsCache
    participant Resolver as resolveEngineConfig
    participant Loader as loadEngine
    participant CacheEngine

    Client->>XoopsCache: write(key, value, duration_or_config)
    activate XoopsCache
    XoopsCache->>Resolver: resolveEngineConfig(config_or_null)
    Resolver-->>XoopsCache: {engine, settings} or false

    alt config valid
        XoopsCache->>Loader: loadEngine(engine_name)
        Loader-->>XoopsCache: engine instance or failure

        alt engine available
            XoopsCache->>CacheEngine: write(key, value, duration)
            CacheEngine-->>XoopsCache: bool
            XoopsCache-->>Client: bool
        else engine missing/invalid
            XoopsCache-->>Client: false
        end
    else invalid config
        XoopsCache-->>Client: false
    end
    deactivate XoopsCache
Loading
sequenceDiagram
    actor Admin
    participant XoopsCache
    participant Resolver as resolveEngineName
    participant Loader as loadEngine
    participant CacheEngine

    Admin->>XoopsCache: engine(name, settings)
    activate XoopsCache
    XoopsCache->>Resolver: resolveEngineName(name)
    Resolver-->>XoopsCache: engine_name or null

    alt valid name
        XoopsCache->>Loader: loadEngine(engine_name)
        Loader-->>XoopsCache: engine instance or failure

        alt initialized
            XoopsCache->>CacheEngine: init(settings)
            CacheEngine-->>XoopsCache: void/bool
            XoopsCache-->>Admin: true/false
        else failed
            XoopsCache-->>Admin: false
        end
    else invalid name
        XoopsCache-->>Admin: false
    end
    deactivate XoopsCache
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hop through configs, engines in a row,

I nudge the loader, give GC a go.
Keys tucked softly where settings abide,
Reads and writes dance on a tidy glide —
A rabbit's cache, spring-cleaned inside.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title mixes two distinct concerns (fixing deprecated null usage and refactoring cache for multiple engines) without clearly prioritizing the main change; it reads as a compound fix statement rather than a coherent summary. Clarify the primary objective—either focus on the deprecated null usage fix or the cache subsystem refactor—and use a single, clear sentence that highlights that main change.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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 unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Copy link

@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.

Caution

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

⚠️ Outside diff range comments (1)
htdocs/class/cache/xoopscache.php (1)

709-718: ⚠️ Potential issue | 🟡 Minor

Empty method stubs can cause TypeError when return value is used.

The delete() and clear() methods have empty bodies that implicitly return null. However, XoopsCache::delete() (line 436) and XoopsCache::clear() (line 471) declare return type : bool and return the result from these engine methods. If a concrete engine class doesn't override these methods, PHP will throw a TypeError.

🔧 Proposed fix: return explicit bool values
     public function delete($key)
     {
+        return false;
     }

     /**
      * Delete all keys from the cache
      *
      * `@param`  boolean $check if true will check expiration, otherwise delete all
      * `@return` boolean True if the cache was successfully cleared, false otherwise
      * `@access` public
      */
     public function clear($check)
     {
+        return false;
     }

Alternatively, make these methods abstract to force concrete implementations.

🧹 Nitpick comments (1)
htdocs/class/cache/xoopscache.php (1)

194-199: Minor: Redundant null check.

The check $_this->configs !== null on line 195 is redundant since $this->configs is initialized as an empty array on line 47 and can never be null. Consider simplifying:

-        } elseif (
-            $_this->configs !== null
-            && is_string($_this->name)
+        } elseif (
+            is_string($_this->name)
             && $_this->name !== ''
             && isset($_this->configs[$_this->name])
         ) {

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

htdocs/class/cache/xoopscache.php:225

  • $engine defaults to 'file' when $settings['engine'] is missing, but that default isn’t written back into $settings/configs. This can leave the active config without an 'engine' key, which breaks resolveEngineName()/isInitialized() when called without an explicit engine. Consider normalizing by setting $settings['engine'] = $engine before initializing/storing the config.
        $engine = (!empty($settings['engine']) && is_string($settings['engine']))
            ? $settings['engine']
            : 'file';

        if ($name !== $_this->name) {
            if ($_this->engine($engine, $settings) === false) {
                trigger_error("Cache Engine {$engine} is not set", E_USER_WARNING);

                return false;
            }
            $_this->name           = $name;
            $_this->configs[$name] = $_this->settings($engine);
        }

}

$gcResult = $_this->engine[$engine]->gc();

Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

gc() now returns (bool) $gcResult. For engines whose gc() returns null/void (e.g., engines that inherit the empty base implementation), this will always return false even though GC may be a no-op/success. Consider standardizing engine gc() to return a boolean, or treat null as success here (e.g., return true when $gcResult === null).

Suggested change
if ($gcResult === null) {
// Treat null/void gc() implementations as a successful no-op
return true;
}

Copilot uses AI. Check for mistakes.
Comment on lines 168 to 183
if (is_array($name)) {
extract($name);
$config = $name;

if (isset($config['name']) && is_string($config['name'])) {
$name = $config['name'];
unset($config['name']);
} else {
$name = 'default';
}

if (isset($config['settings']) && is_array($config['settings'])) {
$settings = $config['settings'];
} else {
// Back-compat: treat array input as settings when no nested "settings" exists
$settings = $config;
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

When $name is passed as an array containing both top-level engine and nested settings, the current logic drops the top-level engine (because $settings is replaced with $config['settings']). This makes inputs like ['engine' => 'memcache', 'settings' => [...]] silently fall back to the default engine. Consider explicitly carrying engine (and possibly other top-level keys) into $settings or honoring the top-level engine when present.

Copilot uses AI. Check for mistakes.
@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Copyable Unit Test Edits

Copyable Edits

tests/unit/htdocs/class/cache/XoopsCacheTest.php

This is a new file.

<?php

use PHPUnit\Framework\TestCase;

require_once dirname(__DIR__, 4) . '/init_new.php';

// Define required constants
if (!defined('XOOPS_URL')) {
    define('XOOPS_URL', 'http://localhost/xoops');
}

require_once XOOPS_TU_ROOT_PATH . '/class/cache/xoopscache.php';

class XoopsCacheTest extends TestCase
{
    protected XoopsCache $cache;

    protected function setUp(): void
    {
        // Get singleton instance
        $this->cache = XoopsCache::getInstance();
    }

    public function testGetInstanceReturnsSameInstance()
    {
        $instance1 = XoopsCache::getInstance();
        $instance2 = XoopsCache::getInstance();

        $this->assertSame($instance1, $instance2, 'getInstance should return the same singleton instance');
    }

    public function testGetInstanceReturnsXoopsCacheObject()
    {
        $instance = XoopsCache::getInstance();

        $this->assertInstanceOf(XoopsCache::class, $instance);
    }

    public function testConfigWithDefaultName()
    {
        $result = $this->cache->config();

        $this->assertIsArray($result);
        $this->assertArrayHasKey('engine', $result);
        $this->assertArrayHasKey('settings', $result);
        $this->assertEquals('file', $result['engine'], 'Default engine should be file');
    }

    public function testConfigWithCustomSettings()
    {
        $settings = [
            'engine' => 'file',
            'path' => '/tmp/cache',
            'duration' => 3600,
        ];

        $result = $this->cache->config('custom', $settings);

        $this->assertIsArray($result);
        $this->assertArrayHasKey('engine', $result);
        $this->assertEquals('file', $result['engine']);
    }

    public function testConfigWithArrayParameter()
    {
        $config = [
            'name' => 'test_config',
            'settings' => [
                'engine' => 'file',
                'duration' => 7200,
            ]
        ];

        $result = $this->cache->config($config);

        $this->assertIsArray($result);
        $this->assertArrayHasKey('engine', $result);
    }

    public function testConfigWithEmptyStringName()
    {
        $result = $this->cache->config('', ['engine' => 'file']);

        $this->assertIsArray($result);
        $this->assertEquals('file', $result['engine']);
    }

    public function testConfigWithNonArraySettings()
    {
        $result = $this->cache->config('test', 'not_array');

        $this->assertIsArray($result);
        $this->assertArrayHasKey('engine', $result);
    }

    public function testEngineWithValidEngine()
    {
        $result = $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $this->assertTrue($result, 'File engine should initialize successfully');
    }

    public function testEngineWithEmptyName()
    {
        $result = $this->cache->engine('', []);

        $this->assertFalse($result, 'Empty engine name should return false');
    }

    public function testEngineWithNonStringName()
    {
        $result = $this->cache->engine(null, []);

        $this->assertFalse($result, 'Null engine name should return false');
    }

    public function testEngineWithNonArraySettings()
    {
        $result = $this->cache->engine('file', 'not_array');

        $this->assertTrue($result, 'Engine should handle non-array settings gracefully');
    }

    public function testEngineWithInvalidEngine()
    {
        // Suppress the expected warning
        $result = @$this->cache->engine('nonexistent_engine', []);

        $this->assertFalse($result, 'Invalid engine should return false');
    }

    public function testKeyGenerationWithValidKey()
    {
        $key = $this->cache->key('test_key');

        $this->assertNotFalse($key);
        $this->assertIsString($key);
        $this->assertEquals('test_key', $key);
    }

    public function testKeyGenerationWithEmptyKey()
    {
        $key = $this->cache->key('');

        $this->assertFalse($key, 'Empty key should return false');
    }

    public function testKeyGenerationWithSlashes()
    {
        $key = $this->cache->key('path/to/key');

        $this->assertNotFalse($key);
        $this->assertEquals('path_to_key', $key, 'Slashes should be replaced with underscores');
    }

    public function testKeyGenerationWithDots()
    {
        $key = $this->cache->key('key.with.dots');

        $this->assertNotFalse($key);
        $this->assertEquals('key_with_dots', $key, 'Dots should be replaced with underscores');
    }

    public function testKeyGenerationWithMixedSpecialChars()
    {
        $key = $this->cache->key('path/to/key.name');

        $this->assertNotFalse($key);
        $this->assertEquals('path_to_key_name', $key);
    }

    public function testIsInitializedWithFileEngine()
    {
        // Initialize file engine
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = $this->cache->isInitialized('file');

        $this->assertTrue($result, 'File engine should be initialized');
    }

    public function testIsInitializedWithNullEngine()
    {
        // Configure default engine
        $this->cache->config('default', ['engine' => 'file']);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = $this->cache->isInitialized(null);

        $this->assertTrue($result, 'Should check current engine when null is passed');
    }

    public function testIsInitializedWithUninitializedEngine()
    {
        $result = $this->cache->isInitialized('nonexistent');

        $this->assertFalse($result, 'Uninitialized engine should return false');
    }

    public function testSettingsWithInitializedEngine()
    {
        // Initialize file engine
        $this->cache->engine('file', ['path' => sys_get_temp_dir(), 'duration' => 3600]);

        $settings = $this->cache->settings('file');

        $this->assertIsArray($settings);
        $this->assertArrayHasKey('duration', $settings);
    }

    public function testSettingsWithUninitializedEngine()
    {
        $settings = $this->cache->settings('nonexistent');

        $this->assertIsArray($settings);
        $this->assertEmpty($settings, 'Uninitialized engine should return empty array');
    }

    public function testSettingsWithNullEngine()
    {
        // Configure and initialize default engine
        $this->cache->config('default', ['engine' => 'file']);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $settings = $this->cache->settings(null);

        $this->assertIsArray($settings);
    }

    public function testWriteWithValidData()
    {
        // Configure file engine
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('test_key', 'test_value', 3600);

        $this->assertTrue($result, 'Write operation should succeed');
    }

    public function testWriteAndReadCycle()
    {
        // Configure file engine
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $key = 'cycle_test_key';
        $value = 'cycle_test_value';

        XoopsCache::write($key, $value, 3600);
        $result = XoopsCache::read($key);

        $this->assertEquals($value, $result, 'Read should return the written value');
    }

    public function testWriteWithResourceValue()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $resource = fopen('php://memory', 'r');
        $result = XoopsCache::write('resource_key', $resource, 3600);
        fclose($resource);

        $this->assertFalse($result, 'Writing resource should return false');
    }

    public function testWriteWithZeroDuration()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('zero_duration', 'value', 0);

        $this->assertFalse($result, 'Zero duration should return false');
    }

    public function testWriteWithNegativeDuration()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('negative_duration', 'value', -100);

        $this->assertFalse($result, 'Negative duration should return false');
    }

    public function testWriteWithStringDuration()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('string_duration', 'value', '+1 hour');

        $this->assertTrue($result, 'String duration should be parsed by strtotime');
    }

    public function testWriteWithArrayDurationConfig()
    {
        $this->cache->config('custom', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('array_duration', 'value', ['config' => 'custom', 'duration' => 3600]);

        $this->assertTrue($result, 'Array duration with config should work');
    }

    public function testWriteWithConfigNameAsDuration()
    {
        $this->cache->config('named_config', ['engine' => 'file', 'path' => sys_get_temp_dir(), 'duration' => 7200]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('config_duration', 'value', 'named_config');

        $this->assertTrue($result, 'Config name as duration should work');
    }

    public function testReadNonExistentKey()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::read('nonexistent_key_12345');

        $this->assertFalse($result, 'Reading nonexistent key should return false');
    }

    public function testDeleteExistingKey()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $key = 'delete_test_key';
        XoopsCache::write($key, 'value', 3600);

        $result = XoopsCache::delete($key);

        $this->assertTrue($result, 'Delete should return true');

        // Verify it's deleted
        $readResult = XoopsCache::read($key);
        $this->assertFalse($readResult, 'Key should no longer exist after deletion');
    }

    public function testDeleteNonExistentKey()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::delete('nonexistent_delete_key');

        // Result may vary by engine, but should not throw error
        $this->assertIsBool($result);
    }

    public function testClearCache()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        // Write some data
        XoopsCache::write('clear_test_1', 'value1', 3600);
        XoopsCache::write('clear_test_2', 'value2', 3600);

        $result = $this->cache->clear(false);

        $this->assertTrue($result, 'Clear should return true');
    }

    public function testClearCacheWithCheck()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = $this->cache->clear(true);

        $this->assertTrue($result, 'Clear with check should return true');
    }

    public function testGarbageCollection()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = $this->cache->gc();

        $this->assertTrue($result, 'Garbage collection should return true');
    }

    public function testGarbageCollectionWithoutInitializedEngine()
    {
        // Create a new instance to avoid initialized engines
        $result = @$this->cache->gc();

        // Should return false without initialized engine
        $this->assertFalse($result);
    }

    public function testWriteWithComplexDataStructure()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $complexData = [
            'string' => 'test',
            'number' => 42,
            'array' => [1, 2, 3],
            'nested' => ['key' => 'value'],
        ];

        XoopsCache::write('complex_data', $complexData, 3600);
        $result = XoopsCache::read('complex_data');

        $this->assertEquals($complexData, $result, 'Complex data should be preserved');
    }

    public function testWriteWithBooleanValue()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        XoopsCache::write('bool_true', true, 3600);
        XoopsCache::write('bool_false', false, 3600);

        $resultTrue = XoopsCache::read('bool_true');
        $resultFalse = XoopsCache::read('bool_false');

        $this->assertTrue($resultTrue);
        $this->assertFalse($resultFalse);
    }

    public function testWriteWithNullValue()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        XoopsCache::write('null_value', null, 3600);
        $result = XoopsCache::read('null_value');

        $this->assertNull($result);
    }

    public function testWriteWithNumericValue()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        XoopsCache::write('int_value', 42, 3600);
        XoopsCache::write('float_value', 3.14, 3600);

        $intResult = XoopsCache::read('int_value');
        $floatResult = XoopsCache::read('float_value');

        $this->assertEquals(42, $intResult);
        $this->assertEquals(3.14, $floatResult);
    }

    public function testKeyPrefixingWithXoopsUrl()
    {
        // The write method prefixes keys with substr(md5(XOOPS_URL), 0, 8)
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::write('prefix_test', 'value', 3600);
        $this->assertTrue($result);

        // Should be able to read it back
        $readResult = XoopsCache::read('prefix_test');
        $this->assertEquals('value', $readResult);
    }

    public function testConfigReturnsFalseOnInvalidConfiguration()
    {
        // Test with invalid config that doesn't have required keys
        $instance = XoopsCache::getInstance();

        // This should still work as config creates default settings
        $result = $instance->config('test', []);

        $this->assertIsArray($result);
    }

    public function testEngineLoadFailsGracefully()
    {
        // Try to load non-existent engine
        $result = @$this->cache->engine('totally_fake_engine_xyz', []);

        $this->assertFalse($result, 'Loading non-existent engine should return false');
    }

    public function testMultipleWritesAndReads()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $testData = [
            'key1' => 'value1',
            'key2' => 'value2',
            'key3' => 'value3',
        ];

        // Write all
        foreach ($testData as $key => $value) {
            XoopsCache::write($key, $value, 3600);
        }

        // Read all and verify
        foreach ($testData as $key => $value) {
            $result = XoopsCache::read($key);
            $this->assertEquals($value, $result, "Key {$key} should match its value");
        }
    }

    public function testWriteOverwritesExistingKey()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $key = 'overwrite_test';

        XoopsCache::write($key, 'original', 3600);
        XoopsCache::write($key, 'updated', 3600);

        $result = XoopsCache::read($key);

        $this->assertEquals('updated', $result, 'Second write should overwrite first');
    }

    public function testConfigPersistsBetweenCalls()
    {
        $this->cache->config('persistent', ['engine' => 'file', 'path' => sys_get_temp_dir()]);

        // Call config again without settings
        $result = $this->cache->config('persistent');

        $this->assertIsArray($result);
        $this->assertEquals('file', $result['engine'], 'Config should persist');
    }

    // Additional edge case and regression tests

    public function testWriteWithVeryLongKey()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $longKey = str_repeat('a', 500);
        $result = XoopsCache::write($longKey, 'value', 3600);

        $this->assertTrue($result);
        $this->assertEquals('value', XoopsCache::read($longKey));
    }

    public function testWriteWithSpecialCharactersInValue()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $specialValue = "Line1\nLine2\tTab\r\n'quotes\"double";
        XoopsCache::write('special_chars', $specialValue, 3600);

        $result = XoopsCache::read('special_chars');
        $this->assertEquals($specialValue, $result);
    }

    public function testConcurrentReadAfterWrite()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        XoopsCache::write('concurrent', 'data', 3600);

        // Multiple reads should all succeed
        $read1 = XoopsCache::read('concurrent');
        $read2 = XoopsCache::read('concurrent');
        $read3 = XoopsCache::read('concurrent');

        $this->assertEquals('data', $read1);
        $this->assertEquals('data', $read2);
        $this->assertEquals('data', $read3);
    }

    public function testDeleteReturnsCorrectType()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $result = XoopsCache::delete('any_key');

        $this->assertIsBool($result, 'Delete should always return boolean');
    }

    public function testEngineInitializationWithEmptySettings()
    {
        $result = $this->cache->engine('file', []);

        $this->assertTrue($result, 'File engine should initialize with empty settings');
    }

    public function testKeyWithNumericInput()
    {
        $key = $this->cache->key(12345);

        $this->assertIsString($key);
        $this->assertEquals('12345', $key);
    }

    public function testReadWriteDeleteCycle()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        $key = 'lifecycle_test';

        // Write
        $writeResult = XoopsCache::write($key, 'data', 3600);
        $this->assertTrue($writeResult);

        // Read
        $readResult = XoopsCache::read($key);
        $this->assertEquals('data', $readResult);

        // Delete
        $deleteResult = XoopsCache::delete($key);
        $this->assertTrue($deleteResult);

        // Read after delete
        $readAfterDelete = XoopsCache::read($key);
        $this->assertFalse($readAfterDelete);
    }

    public function testWriteWithLargeDataStructure()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        // Create a large nested array
        $largeData = [];
        for ($i = 0; $i < 100; $i++) {
            $largeData["key_$i"] = [
                'id' => $i,
                'name' => "Item $i",
                'data' => str_repeat('x', 100),
                'nested' => ['a' => 1, 'b' => 2, 'c' => 3]
            ];
        }

        $result = XoopsCache::write('large_data', $largeData, 3600);
        $this->assertTrue($result);

        $retrieved = XoopsCache::read('large_data');
        $this->assertEquals($largeData, $retrieved);
    }

    public function testConfigWithMultipleEngines()
    {
        // Configure multiple engines
        $this->cache->config('file_cache', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->config('second_cache', ['engine' => 'file', 'path' => sys_get_temp_dir() . '/cache2']);

        $config1 = $this->cache->config('file_cache');
        $config2 = $this->cache->config('second_cache');

        $this->assertIsArray($config1);
        $this->assertIsArray($config2);
        $this->assertEquals('file', $config1['engine']);
        $this->assertEquals('file', $config2['engine']);
    }

    public function testWriteRejectsInvalidDurationFormats()
    {
        $this->cache->config('default', ['engine' => 'file', 'path' => sys_get_temp_dir()]);
        $this->cache->engine('file', ['path' => sys_get_temp_dir()]);

        // Invalid duration string that can't be parsed
        $result = XoopsCache::write('invalid_duration', 'value', 'not a valid time string');

        // Should handle gracefully - either false or use default duration
        $this->assertIsBool($result);
    }
}

tests/unit/htdocs/include/VersionTest.php

This is a new file.

<?php

use PHPUnit\Framework\TestCase;

require_once dirname(__DIR__, 3) . '/init_new.php';

// Define XOOPS_ROOT_PATH if not already defined
if (!defined('XOOPS_ROOT_PATH')) {
    define('XOOPS_ROOT_PATH', XOOPS_TU_ROOT_PATH);
}

require_once XOOPS_TU_ROOT_PATH . '/include/version.php';

class VersionTest extends TestCase
{
    public function testXoopsVersionConstantIsDefined()
    {
        $this->assertTrue(defined('XOOPS_VERSION'), 'XOOPS_VERSION constant should be defined');
    }

    public function testXoopsVersionIsNotEmpty()
    {
        $this->assertNotEmpty(XOOPS_VERSION, 'XOOPS_VERSION should not be empty');
    }

    public function testXoopsVersionIsString()
    {
        $this->assertIsString(XOOPS_VERSION, 'XOOPS_VERSION should be a string');
    }

    public function testXoopsVersionFormat()
    {
        // Version should start with "XOOPS"
        $this->assertStringStartsWith('XOOPS', XOOPS_VERSION, 'Version should start with XOOPS');
    }

    public function testXoopsVersionContainsVersionNumber()
    {
        // Should contain a version number pattern (e.g., 2.5.12)
        $this->assertMatchesRegularExpression(
            '/\d+\.\d+\.\d+/',
            XOOPS_VERSION,
            'Version should contain a semantic version number (x.y.z)'
        );
    }

    public function testXoopsVersionValue()
    {
        // Test the exact version value
        $this->assertEquals('XOOPS 2.5.12-Beta9', XOOPS_VERSION, 'Version should match expected value');
    }

    public function testXoopsVersionContainsXoopsPrefix()
    {
        // Verify the format "XOOPS x.y.z"
        $pattern = '/^XOOPS\s+\d+\.\d+\.\d+/';
        $this->assertMatchesRegularExpression(
            $pattern,
            XOOPS_VERSION,
            'Version should follow format "XOOPS x.y.z"'
        );
    }

    public function testXoopsVersionMayContainStatusSuffix()
    {
        // Version may contain status like -Beta, -RC, -Alpha, or be stable
        $validPatterns = [
            '/^XOOPS\s+\d+\.\d+\.\d+$/',                    // Stable
            '/^XOOPS\s+\d+\.\d+\.\d+-Alpha\d*$/',           // Alpha
            '/^XOOPS\s+\d+\.\d+\.\d+-Beta\d*$/',            // Beta
            '/^XOOPS\s+\d+\.\d+\.\d+-RC\d*$/',              // Release Candidate
            '/^XOOPS\s+\d+\.\d+\.\d+-Dev$/',                // Development
        ];

        $matchesAny = false;
        foreach ($validPatterns as $pattern) {
            if (preg_match($pattern, XOOPS_VERSION)) {
                $matchesAny = true;
                break;
            }
        }

        $this->assertTrue($matchesAny, 'Version should match one of the valid format patterns');
    }

    public function testVersionFileRequiresXoopsRootPath()
    {
        // The version.php file should check for XOOPS_ROOT_PATH
        // We can't easily test this without re-including, but we can verify it's set
        $this->assertTrue(
            defined('XOOPS_ROOT_PATH'),
            'XOOPS_ROOT_PATH should be defined before including version.php'
        );
    }

    public function testXoopsVersionIsReadOnly()
    {
        // Attempt to redefine should fail (PHP will issue a notice/warning)
        // We test that the constant cannot be changed
        $originalValue = XOOPS_VERSION;

        // Constants can't be redefined, so this test ensures the constant system works
        $this->assertEquals($originalValue, XOOPS_VERSION, 'Constant should remain unchanged');
    }

    public function testVersionStringLength()
    {
        // Version string should be reasonable length
        $length = strlen(XOOPS_VERSION);
        $this->assertGreaterThan(5, $length, 'Version string should be longer than 5 characters');
        $this->assertLessThan(50, $length, 'Version string should be less than 50 characters');
    }

    public function testVersionMajorNumber()
    {
        // Extract major version (should be 2 for XOOPS 2.x)
        preg_match('/XOOPS\s+(\d+)\./', XOOPS_VERSION, $matches);
        $this->assertNotEmpty($matches, 'Should extract major version');
        $majorVersion = (int)$matches[1];
        $this->assertEquals(2, $majorVersion, 'Major version should be 2');
    }

    public function testVersionMinorNumber()
    {
        // Extract minor version (should be 5 for XOOPS 2.5.x)
        preg_match('/XOOPS\s+\d+\.(\d+)\./', XOOPS_VERSION, $matches);
        $this->assertNotEmpty($matches, 'Should extract minor version');
        $minorVersion = (int)$matches[1];
        $this->assertEquals(5, $minorVersion, 'Minor version should be 5');
    }

    public function testVersionPatchNumber()
    {
        // Extract patch version
        preg_match('/XOOPS\s+\d+\.\d+\.(\d+)/', XOOPS_VERSION, $matches);
        $this->assertNotEmpty($matches, 'Should extract patch version');
        $patchVersion = (int)$matches[1];
        $this->assertGreaterThanOrEqual(0, $patchVersion, 'Patch version should be non-negative');
    }

    public function testVersionForComparison()
    {
        // Test that version can be compared with version_compare
        $testVersion = '2.5.0';

        // Extract numeric version from XOOPS_VERSION
        preg_match('/(\d+\.\d+\.\d+)/', XOOPS_VERSION, $matches);
        $numericVersion = $matches[1];

        $comparison = version_compare($numericVersion, $testVersion);
        $this->assertIsInt($comparison, 'version_compare should work with XOOPS version');
    }

    public function testCurrentVersionGreaterThan2510()
    {
        // Extract numeric version
        preg_match('/(\d+\.\d+\.\d+)/', XOOPS_VERSION, $matches);
        $numericVersion = $matches[1];

        $this->assertGreaterThanOrEqual(
            0,
            version_compare($numericVersion, '2.5.10'),
            'Current version should be 2.5.10 or higher'
        );
    }

    public function testVersionConsistency()
    {
        // Calling the constant multiple times should return same value
        $value1 = XOOPS_VERSION;
        $value2 = XOOPS_VERSION;

        $this->assertSame($value1, $value2, 'Version constant should be consistent');
    }

    public function testVersionNotContainHtml()
    {
        // Version string should not contain HTML tags
        $this->assertEquals(
            XOOPS_VERSION,
            strip_tags(XOOPS_VERSION),
            'Version should not contain HTML tags'
        );
    }

    public function testVersionNotContainLeadingOrTrailingWhitespace()
    {
        // Version should not have leading/trailing whitespace
        $this->assertEquals(
            XOOPS_VERSION,
            trim(XOOPS_VERSION),
            'Version should not have leading or trailing whitespace'
        );
    }

    public function testVersionBetaStatus()
    {
        // Current version is Beta9
        $this->assertStringContainsString('Beta', XOOPS_VERSION, 'Current version should be Beta');
        $this->assertStringContainsString('Beta9', XOOPS_VERSION, 'Current version should be Beta9');
    }

    public function testExtractionOfStatusFromVersion()
    {
        // Extract status (Beta, Alpha, RC, etc.)
        if (preg_match('/-([A-Za-z]+\d*)$/', XOOPS_VERSION, $matches)) {
            $status = $matches[1];
            $this->assertNotEmpty($status, 'Status should be extractable from version');
            $this->assertEquals('Beta9', $status, 'Status should be Beta9');
        } else {
            // If no match, version is stable
            $this->assertStringNotContainsString('-', XOOPS_VERSION);
        }
    }

    public function testVersionSemanticFormat()
    {
        // Test full semantic versioning format: MAJOR.MINOR.PATCH[-STATUS]
        $pattern = '/^XOOPS\s+\d+\.\d+\.\d+(?:-[A-Za-z]+\d*)?$/';
        $this->assertMatchesRegularExpression(
            $pattern,
            XOOPS_VERSION,
            'Version should follow semantic versioning format'
        );
    }

    // Additional regression and edge case tests

    public function testVersionStringDoesNotContainNull()
    {
        $this->assertStringNotContainsString("\0", XOOPS_VERSION, 'Version should not contain null bytes');
    }

    public function testVersionCanBeUsedInFileNames()
    {
        // Version string should be safe for filenames (no /, \, etc.)
        $unsafeChars = ['/', '\\', '*', '?', '"', '<', '>', '|'];

        foreach ($unsafeChars as $char) {
            $this->assertStringNotContainsString(
                $char,
                XOOPS_VERSION,
                "Version should not contain unsafe filename character: {$char}"
            );
        }
    }

    public function testVersionCanBeUsedInUrls()
    {
        // Version should be URL-safe or easily encoded
        $encoded = urlencode(XOOPS_VERSION);
        $this->assertNotEmpty($encoded);

        // Should decode back to original
        $decoded = urldecode($encoded);
        $this->assertEquals(XOOPS_VERSION, $decoded);
    }

    public function testVersionCanBeJsonEncoded()
    {
        $json = json_encode(['version' => XOOPS_VERSION]);
        $this->assertNotFalse($json);

        $decoded = json_decode($json, true);
        $this->assertEquals(XOOPS_VERSION, $decoded['version']);
    }

    public function testVersionConstantTypeConsistency()
    {
        // Multiple calls should return same type
        $type1 = gettype(XOOPS_VERSION);
        $type2 = gettype(XOOPS_VERSION);

        $this->assertEquals($type1, $type2);
        $this->assertEquals('string', $type1);
    }

    public function testVersionParseable()
    {
        // Should be able to extract version components
        preg_match('/XOOPS\s+(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+\d*))?/', XOOPS_VERSION, $matches);

        $this->assertNotEmpty($matches, 'Version should be parseable');
        $this->assertGreaterThanOrEqual(4, count($matches), 'Should extract at least major.minor.patch');

        // Validate extracted components
        $major = (int)$matches[1];
        $minor = (int)$matches[2];
        $patch = (int)$matches[3];

        $this->assertGreaterThanOrEqual(0, $major);
        $this->assertGreaterThanOrEqual(0, $minor);
        $this->assertGreaterThanOrEqual(0, $patch);
    }
}

tests/unit/htdocs/kernel/XoopsModuleTest.php

This is a new file.

<?php

use PHPUnit\Framework\TestCase;

require_once dirname(__DIR__, 3) . '/init_new.php';

// Define required constants
if (!defined('XOOPS_ROOT_PATH')) {
    define('XOOPS_ROOT_PATH', XOOPS_TU_ROOT_PATH);
}
if (!defined('XOOPS_URL')) {
    define('XOOPS_URL', 'http://localhost/xoops');
}

require_once XOOPS_TU_ROOT_PATH . '/kernel/object.php';
require_once XOOPS_TU_ROOT_PATH . '/kernel/module.php';

class XoopsModuleTest extends TestCase
{
    protected XoopsModule $module;

    protected function setUp(): void
    {
        $this->module = new XoopsModule();
    }

    public function testConstructorInitializesObject()
    {
        $module = new XoopsModule();

        $this->assertInstanceOf(XoopsModule::class, $module);
        $this->assertInstanceOf(XoopsObject::class, $module);
    }

    public function testConstructorInitializesAllVariables()
    {
        $module = new XoopsModule();

        // Test that all expected vars are initialized
        $this->assertIsInt($module->getVar('mid'));
        $this->assertIsInt($module->getVar('weight'));
        $this->assertIsInt($module->getVar('isactive'));
    }

    public function testSetMessageAddsMessage()
    {
        $this->module->setMessage('Test message');

        $messages = $this->module->getMessages();

        $this->assertIsArray($messages);
        $this->assertCount(1, $messages);
        $this->assertEquals('Test message', $messages[0]);
    }

    public function testSetMessageTrimsWhitespace()
    {
        $this->module->setMessage('  Test message  ');

        $messages = $this->module->getMessages();

        $this->assertEquals('Test message', $messages[0]);
    }

    public function testSetMessageMultipleMessages()
    {
        $this->module->setMessage('Message 1');
        $this->module->setMessage('Message 2');
        $this->module->setMessage('Message 3');

        $messages = $this->module->getMessages();

        $this->assertCount(3, $messages);
        $this->assertEquals('Message 1', $messages[0]);
        $this->assertEquals('Message 2', $messages[1]);
        $this->assertEquals('Message 3', $messages[2]);
    }

    public function testGetMessagesReturnsArray()
    {
        $messages = $this->module->getMessages();

        $this->assertIsArray($messages);
    }

    public function testGetMessagesInitiallyEmpty()
    {
        $messages = $this->module->getMessages();

        $this->assertEmpty($messages);
    }

    public function testSetInfoWithName()
    {
        $result = $this->module->setInfo('test_key', 'test_value');

        $this->assertTrue($result);

        $info = $this->module->getInfo('test_key');
        $this->assertEquals('test_value', $info);
    }

    public function testSetInfoWithEmptyName()
    {
        $value = ['key1' => 'value1', 'key2' => 'value2'];
        $result = $this->module->setInfo('', $value);

        $this->assertTrue($result);

        $info = $this->module->getInfo();
        $this->assertEquals($value, $info);
    }

    public function testSetInfoWithArrayValue()
    {
        $arrayValue = ['item1', 'item2', 'item3'];
        $result = $this->module->setInfo('array_key', $arrayValue);

        $this->assertTrue($result);

        $info = $this->module->getInfo('array_key');
        $this->assertEquals($arrayValue, $info);
    }

    public function testGetInfoWithoutName()
    {
        $this->module->setInfo('key1', 'value1');
        $this->module->setInfo('key2', 'value2');

        $info = $this->module->getInfo();

        $this->assertIsArray($info);
        $this->assertArrayHasKey('key1', $info);
        $this->assertArrayHasKey('key2', $info);
    }

    public function testGetInfoWithNonExistentKey()
    {
        $result = $this->module->getInfo('nonexistent_key');

        $this->assertFalse($result);
    }

    public function testGetInfoReturnsReference()
    {
        $this->module->setInfo('ref_key', 'original');

        $info = &$this->module->getInfo('ref_key');
        $info = 'modified';

        $this->assertEquals('modified', $this->module->getInfo('ref_key'));
    }

    public function testGetStatusWithVersionString()
    {
        $this->module->setVar('version', '2.5.12-Beta9');

        $status = $this->module->getStatus();

        $this->assertEquals('Beta9', $status);
    }

    public function testGetStatusWithoutStatus()
    {
        $this->module->setVar('version', '2.5.12');

        $status = $this->module->getStatus();

        $this->assertEmpty($status);
    }

    public function testGetStatusWithAlpha()
    {
        $this->module->setVar('version', '1.0.0-Alpha1');

        $status = $this->module->getStatus();

        $this->assertEquals('Alpha1', $status);
    }

    public function testGetStatusWithRC()
    {
        $this->module->setVar('version', '3.0.0-RC2');

        $status = $this->module->getStatus();

        $this->assertEquals('RC2', $status);
    }

    public function testVersionCompareWithLessThan()
    {
        $result = $this->module->versionCompare('1.0.0', '2.0.0', '<');

        $this->assertTrue($result);
    }

    public function testVersionCompareWithGreaterThan()
    {
        $result = $this->module->versionCompare('2.0.0', '1.0.0', '>');

        $this->assertTrue($result);
    }

    public function testVersionCompareWithEqual()
    {
        $result = $this->module->versionCompare('1.5.0', '1.5.0', '=');

        $this->assertTrue($result);
    }

    public function testVersionCompareWithStableSuffix()
    {
        // -stable suffix should be stripped for comparison
        $result = $this->module->versionCompare('1.0.0-stable', '1.0.0', '=');

        $this->assertTrue($result);
    }

    public function testVersionCompareIsCaseInsensitive()
    {
        $result = $this->module->versionCompare('1.0.0-STABLE', '1.0.0-stable', '=');

        $this->assertTrue($result);
    }

    public function testVersionCompareWithComplexVersions()
    {
        $result = $this->module->versionCompare('2.5.11-stable', '2.5.12', '<');

        $this->assertTrue($result);
    }

    public function testMainLinkWithHasMain()
    {
        $this->module->setVar('hasmain', 1);
        $this->module->setVar('dirname', 'testmodule');
        $this->module->setVar('name', 'Test Module');

        $link = $this->module->mainLink();

        $this->assertIsString($link);
        $this->assertStringContainsString('testmodule', $link);
        $this->assertStringContainsString('Test Module', $link);
        $this->assertStringContainsString('<a href=', $link);
    }

    public function testMainLinkWithoutHasMain()
    {
        $this->module->setVar('hasmain', 0);

        $link = $this->module->mainLink();

        $this->assertFalse($link);
    }

    public function testMainLinkFormat()
    {
        $this->module->setVar('hasmain', 1);
        $this->module->setVar('dirname', 'news');
        $this->module->setVar('name', 'News');

        $link = $this->module->mainLink();

        $expectedUrl = XOOPS_URL . '/modules/news/';
        $this->assertStringContainsString($expectedUrl, $link);
    }

    public function testSubLinkWithNoSubMenu()
    {
        $result = $this->module->subLink();

        $this->assertIsArray($result);
        $this->assertEmpty($result);
    }

    public function testSubLinkWithSubMenu()
    {
        $subMenu = [
            ['id' => 1, 'name' => 'Submenu 1', 'url' => 'page1.php', 'icon' => 'icon1.png'],
            ['id' => 2, 'name' => 'Submenu 2', 'url' => 'page2.php', 'icon' => 'icon2.png'],
        ];

        $this->module->setInfo('sub', $subMenu);

        $result = $this->module->subLink();

        $this->assertIsArray($result);
        $this->assertCount(2, $result);
        $this->assertEquals('Submenu 1', $result[0]['name']);
        $this->assertEquals('page1.php', $result[0]['url']);
    }

    public function testSubLinkWithMissingOptionalFields()
    {
        $subMenu = [
            ['name' => 'Submenu 1', 'url' => 'page1.php'],
        ];

        $this->module->setInfo('sub', $subMenu);

        $result = $this->module->subLink();

        $this->assertIsArray($result);
        $this->assertCount(1, $result);
        $this->assertEquals('', $result[0]['id']);
        $this->assertEquals('', $result[0]['icon']);
    }

    public function testIdMethod()
    {
        $this->module->setVar('mid', 42);

        $id = $this->module->id();

        $this->assertEquals(42, $id);
    }

    public function testIdMethodWithFormat()
    {
        $this->module->setVar('mid', 42);

        $id = $this->module->id('S');

        $this->assertIsString($id);
    }

    public function testMidMethod()
    {
        $this->module->setVar('mid', 123);

        $mid = $this->module->mid();

        $this->assertEquals(123, $mid);
    }

    public function testNameMethod()
    {
        $this->module->setVar('name', 'Test Module');

        $name = $this->module->name();

        $this->assertEquals('Test Module', $name);
    }

    public function testVersionMethod()
    {
        $this->module->setVar('version', '1.0.0');

        $version = $this->module->version();

        $this->assertEquals('1.0.0', $version);
    }

    public function testLastUpdateMethod()
    {
        $timestamp = time();
        $this->module->setVar('last_update', $timestamp);

        $lastUpdate = $this->module->last_update();

        $this->assertEquals($timestamp, $lastUpdate);
    }

    public function testWeightMethod()
    {
        $this->module->setVar('weight', 10);

        $weight = $this->module->weight();

        $this->assertEquals(10, $weight);
    }

    public function testIsActiveMethod()
    {
        $this->module->setVar('isactive', 1);

        $isActive = $this->module->isactive();

        $this->assertEquals(1, $isActive);
    }

    public function testDirnameMethod()
    {
        $this->module->setVar('dirname', 'mymodule');

        $dirname = $this->module->dirname();

        $this->assertEquals('mymodule', $dirname);
    }

    public function testHasMainMethod()
    {
        $this->module->setVar('hasmain', 1);

        $hasMain = $this->module->hasmain();

        $this->assertEquals(1, $hasMain);
    }

    public function testHasAdminMethod()
    {
        $this->module->setVar('hasadmin', 1);

        $hasAdmin = $this->module->hasadmin();

        $this->assertEquals(1, $hasAdmin);
    }

    public function testHasSearchMethod()
    {
        $this->module->setVar('hassearch', 1);

        $hasSearch = $this->module->hassearch();

        $this->assertEquals(1, $hasSearch);
    }

    public function testHasConfigMethod()
    {
        $this->module->setVar('hasconfig', 1);

        $hasConfig = $this->module->hasconfig();

        $this->assertEquals(1, $hasConfig);
    }

    public function testHasCommentsMethod()
    {
        $this->module->setVar('hascomments', 1);

        $hasComments = $this->module->hascomments();

        $this->assertEquals(1, $hasComments);
    }

    public function testHasNotificationMethod()
    {
        $this->module->setVar('hasnotification', 1);

        $hasNotification = $this->module->hasnotification();

        $this->assertEquals(1, $hasNotification);
    }

    public function testSearchWithoutSearchCapability()
    {
        $this->module->setVar('hassearch', 0);

        $result = $this->module->search('test');

        $this->assertFalse($result);
    }

    public function testSearchWithSearchCapabilityButNoConfig()
    {
        $this->module->setVar('hassearch', 1);

        $result = $this->module->search('test');

        $this->assertFalse($result);
    }

    public function testDeprecatedCheckAccessMethod()
    {
        // Create a mock logger if needed
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->checkAccess();

        $this->assertFalse($result);
    }

    public function testDeprecatedLoadLanguageMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->loadLanguage();

        $this->assertFalse($result);
    }

    public function testDeprecatedLoadErrorMessagesMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->loadErrorMessages();

        $this->assertFalse($result);
    }

    public function testDeprecatedGetCurrentPageMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->getCurrentPage();

        $this->assertFalse($result);
    }

    public function testDeprecatedInstallMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->install();

        $this->assertFalse($result);
    }

    public function testDeprecatedUpdateMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->update();

        $this->assertFalse($result);
    }

    public function testDeprecatedInsertMethod()
    {
        if (!isset($GLOBALS['xoopsLogger'])) {
            $GLOBALS['xoopsLogger'] = $this->createMock(\stdClass::class);
            $GLOBALS['xoopsLogger']->method('addDeprecated')->willReturn(true);
        }

        $result = $this->module->insert();

        $this->assertFalse($result);
    }

    public function testVersionCompareHandlesDifferentOperators()
    {
        $operators = ['<', '>', '<=', '>=', '==', '!='];

        foreach ($operators as $operator) {
            $result = $this->module->versionCompare('1.0.0', '2.0.0', $operator);
            $this->assertIsBool($result, "Operator {$operator} should return boolean");
        }
    }

    public function testSetInfoOverwritesExistingValue()
    {
        $this->module->setInfo('key', 'original');
        $this->module->setInfo('key', 'updated');

        $info = $this->module->getInfo('key');

        $this->assertEquals('updated', $info);
    }

    public function testModulePropertiesArePublic()
    {
        // Test that dynamic properties are accessible
        $this->module->mid = 1;
        $this->module->name = 'Test';
        $this->module->dirname = 'testdir';

        $this->assertEquals(1, $this->module->mid);
        $this->assertEquals('Test', $this->module->name);
        $this->assertEquals('testdir', $this->module->dirname);
    }

    public function testGetAdminMenuInitializes()
    {
        $menu = $this->module->getAdminMenu();

        $this->assertIsArray($menu);
    }

    public function testVersionCompareWithEmptyVersions()
    {
        $result = $this->module->versionCompare('', '', '=');

        $this->assertTrue($result);
    }

    public function testMainLinkEscapesHtml()
    {
        $this->module->setVar('hasmain', 1);
        $this->module->setVar('dirname', 'test<script>');
        $this->module->setVar('name', 'Test<script>');

        $link = $this->module->mainLink();

        // Should contain the raw values in appropriate contexts
        $this->assertIsString($link);
    }

    public function testVersionCompareDefaultOperator()
    {
        // Default operator is '<'
        $result = $this->module->versionCompare('1.0.0', '2.0.0');

        $this->assertTrue($result);
    }

    public function testSubLinkFiltersNonArraySubInfo()
    {
        $this->module->setInfo('sub', 'not an array');

        $result = $this->module->subLink();

        $this->assertIsArray($result);
        $this->assertEmpty($result);
    }

    public function testMessageArrayPreservesOrder()
    {
        $messages = ['First', 'Second', 'Third', 'Fourth'];

        foreach ($messages as $msg) {
            $this->module->setMessage($msg);
        }

        $result = $this->module->getMessages();

        $this->assertEquals($messages, $result);
    }

    public function testGetInfoHandlesNullModinfo()
    {
        // When modinfo is not set, getInfo should handle it gracefully
        $module = new XoopsModule();

        // This will trigger loadInfo which may fail, but should not crash
        $result = @$module->getInfo('some_key');

        // Should return false when modinfo is not properly loaded
        $this->assertFalse($result);
    }

    // Additional edge case and boundary tests

    public function testSetInfoWithNumericName()
    {
        $result = $this->module->setInfo(123, 'value');

        $this->assertTrue($result);
        $this->assertEquals('value', $this->module->getInfo(123));
    }

    public function testVersionCompareWithMixedCaseStable()
    {
        // Test various case combinations
        $result1 = $this->module->versionCompare('1.0.0-STABLE', '1.0.0-stable', '=');
        $result2 = $this->module->versionCompare('1.0.0-Stable', '1.0.0', '=');

        $this->assertTrue($result1);
        $this->assertTrue($result2);
    }

    public function testGettersReturnCorrectFormat()
    {
        // Set various types and ensure getters work
        $this->module->setVar('mid', 99);
        $this->module->setVar('weight', 5);
        $this->module->setVar('isactive', 0);

        $this->assertIsInt($this->module->id('N'));
        $this->assertIsInt($this->module->weight());
        $this->assertEquals(0, $this->module->isactive());
    }

    public function testSubLinkWithEmptyArray()
    {
        $this->module->setInfo('sub', []);

        $result = $this->module->subLink();

        $this->assertIsArray($result);
        $this->assertEmpty($result);
    }

    public function testVersionCompareWithOnlyStableSuffix()
    {
        $result = $this->module->versionCompare('2.5.12-stable', '2.5.12', '=');

        $this->assertTrue($result, 'Versions with -stable suffix should equal versions without');
    }

    public function testMainLinkReturnsCorrectType()
    {
        // Test when hasmain is 0
        $this->module->setVar('hasmain', 0);
        $result = $this->module->mainLink();
        $this->assertFalse($result);

        // Test when hasmain is 1
        $this->module->setVar('hasmain', 1);
        $this->module->setVar('dirname', 'test');
        $this->module->setVar('name', 'Test');
        $result = $this->module->mainLink();
        $this->assertIsString($result);
    }

    public function testGetStatusWithMultipleHyphens()
    {
        $this->module->setVar('version', '1.0.0-RC-2');

        $status = $this->module->getStatus();

        // Should get everything after the last hyphen, or specific parsing logic
        $this->assertIsString($status);
    }

    public function testSetMessageWithEmptyString()
    {
        $this->module->setMessage('');

        $messages = $this->module->getMessages();

        // Empty string should still be trimmed and added
        $this->assertCount(1, $messages);
        $this->assertEquals('', $messages[0]);
    }

    public function testVersionCompareBoundaryVersions()
    {
        // Test boundary conditions
        $result1 = $this->module->versionCompare('0.0.0', '0.0.1', '<');
        $result2 = $this->module->versionCompare('999.999.999', '1000.0.0', '<');

        $this->assertTrue($result1);
        $this->assertTrue($result2);
    }

    public function testGetInfoReturnsArrayReference()
    {
        $this->module->setInfo('key1', 'value1');
        $this->module->setInfo('key2', 'value2');

        $info = &$this->module->getInfo();

        $this->assertIsArray($info);
        $this->assertCount(2, $info);
    }

    public function testIconMethodReturnsValue()
    {
        // Set icon via setVar if the property exists
        @$this->module->setVar('icon', 'module_icon.png');

        $icon = @$this->module->icon();

        // Should return something (may be null if var not initialized)
        $this->assertIsString($icon);
    }
}

Copy link

@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: 1

🤖 Fix all issues with AI agents
In `@htdocs/class/cache/apc.php`:
- Around line 84-87: Update the PHPDoc return types to match the actual boolean
casts: change the `@return` annotations for the write method (public function
write) and the delete method (public function delete) in the APC cache class
from array (or mixed/other) to bool, and scan the class for any other docblocks
that still claim array returns and update them to bool so the docs match the
(bool) casted return values from apc_store/apc_delete.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

Comment on lines +78 to +86
* @param string $key Identifier for the data
* @param mixed $value Data to be cached
* @param integer $duration How long to cache the data, in seconds
* @return bool|array Returns TRUE on success or FALSE on failure | array with error keys.
* @access public
*/
public function write($key, $value, $duration = null)
{
return apc_store($key, $value, $duration);
return (bool) apc_store($key, $value, $duration);
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The write() return type in the docblock (bool|array) no longer matches the implementation since the result is now cast to bool. Please update the PHPDoc to reflect the actual bool return value (and clarify the behavior for array keys, if that’s still supported).

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +110
* @param string $key Identifier for the data
* @return bool|string[] Returns TRUE on success or FALSE on failure. For array of keys returns list of failed keys.
* @access public
*/
public function delete($key)
{
return apc_delete($key);
return (bool) apc_delete($key);
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The delete() return type in the docblock (bool|string[]) no longer matches the implementation since the result is now cast to bool. Please update the PHPDoc to reflect the actual bool return value.

Copilot uses AI. Check for mistakes.

$engine = $config['engine'];
$settings = $config['settings'];

Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

resolveEngineConfig() provides $settings to write(), but write() later assumes keys like duration exist in $settings when $duration is not provided. For engines that implement XoopsCacheEngineInterface without extending XoopsCacheEngine (or that don’t populate defaults in settings()), this can lead to undefined index notices. Consider ensuring required defaults (e.g., duration) are present when resolving config / initializing engines, or defensively defaulting when the key is absent.

Suggested change
if (!isset($settings['duration'])) {
$settings['duration'] = 0;
}

Copilot uses AI. Check for mistakes.
Comment on lines 133 to 139
if (!class_exists('XoopsCache' . ucfirst($name))) {
if (file_exists($file = __DIR__ . '/' . strtolower($name) . '.php')) {
$baseDir = realpath(__DIR__);
$candidate = __DIR__ . '/' . strtolower($name) . '.php';
$file = $baseDir !== false ? realpath($candidate) : false;

if ($file !== false && strpos($file, $baseDir . DIRECTORY_SEPARATOR) === 0) {
include $file;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

loadEngine() returns true after including the candidate file without re-checking that the expected engine class was actually declared. If the file exists but doesn’t define XoopsCache{Name} (or defines a wrong class), new $cacheClass() will fatally error. Re-check class_exists($cacheClass, false) after include and return false / warn if it’s still missing.

Copilot uses AI. Check for mistakes.
Comment on lines 406 to 412
* @param string|array|null $config name of the configuration to use
* @return mixed The cached data, or false if:
* - Configuration is invalid
* - Data doesn't exist
* - Data has expired
* - Error occurred during retrieval
* @access public
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The read() PHPDoc claims it returns false when data doesn’t exist/has expired, but at least one built-in engine (XoopsCacheModel::read) returns null on a miss. Either normalize null to false in XoopsCache::read() (to make behavior consistent across engines) or adjust the docblock to document possible null return values.

Copilot uses AI. Check for mistakes.
Copy link

@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: 1

🤖 Fix all issues with AI agents
In `@htdocs/class/cache/xoopscache.php`:
- Around line 445-448: The code normalizes a cached null to false which makes a
stored null indistinguishable from a miss; change the return to preserve actual
cached values by returning $result directly instead of converting null to false
(i.e., remove the ternary normalization after calling
$_this->engine[$engine]->read($key)). If you need to distinguish misses from
stored nulls, add or use an explicit miss sentinel or a separate exists/has
method on the cache engine rather than coalescing null to false; reference the
engine read call ($_this->engine[$engine]->read) and the $result variable when
applying the change.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +348 to 350
public static function write($key, $value, $duration = null): bool
{
$key = substr(md5(XOOPS_URL), 0, 8) . '_' . $key;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

XoopsCache::write() now has a : bool return type, but the underlying engine write() implementations are not guaranteed to return a boolean (e.g. the bundled DB/model engine ultimately calls XoopsPersistableObjectHandler::insert(), which returns an object ID). If a non-bool bubbles up, PHP will throw a TypeError due to the declared return type. Normalize the engine result before returning (e.g., cast to bool or map non-false values to true).

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comment on lines 590 to 656
interface XoopsCacheEngineInterface
{
/**
* Initialize the cache engine
*
* @param array $settings Associative array of parameters for the engine
* @return bool True if the engine has been successfully initialized, false if not
*/
public function init($settings = []);

/**
* Garbage collection - permanently remove all expired and deleted data
*
* @return bool|null True on success, false on failure, null for no-op
*/
public function gc();

/**
* Write value for a key into cache
*
* @param string $key Identifier for the data
* @param mixed $value Data to be cached
* @param int|null $duration How long to cache the data, in seconds
* @return bool True if the data was successfully cached, false on failure
*/
public function write($key, $value, $duration = null);

/**
* Read a key from the cache
*
* @param string $key Identifier for the data
* @return mixed The cached data, or false if the data doesn't exist or has expired
*/
public function read($key);

/**
* Delete a key from the cache
*
* @param string $key Identifier for the data
* @return bool True if the value was successfully deleted, false otherwise
*/
public function delete($key);

/**
* Delete all keys from the cache
*
* @param bool $check If true will check expiration, otherwise delete all
* @return bool True if the cache was successfully cleared, false otherwise
*/
public function clear($check);

/**
* Return the settings for this cache engine
*
* @return array Associative array of current engine settings
*/
public function settings();
}

/**
* Abstract class for storage engine for caching
*
* @package core
* @subpackage cache
*/
class XoopsCacheEngine
abstract class XoopsCacheEngine implements XoopsCacheEngineInterface
{
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

This PR introduces significant refactoring to the cache subsystem including new interface requirements, multi-engine support, and changes to return type semantics. However, there are no automated tests for the cache system. Consider adding unit tests to cover the new functionality, especially the interface implementation checks, engine resolution logic, configuration parsing, and return type normalization to prevent regressions.

Copilot uses AI. Check for mistakes.
* @license GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
* @link https://xoops.org
* @since 2.5.12
* @deprecated 4.0.0 This interface will be removed in a future version.
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The interface XoopsCacheEngineInterface is marked as deprecated in version 4.0.0, but it's being newly introduced in this PR. Consider removing the deprecation notice since this is a new interface being added to the codebase. If the interface is truly intended to be deprecated immediately upon introduction, this should be explained in the documentation with a clear migration path.

Suggested change
* @deprecated 4.0.0 This interface will be removed in a future version.

Copilot uses AI. Check for mistakes.
/**
* Garbage collection - permanently remove all expired and deleted data
*
* @return bool|null True on success, false on failure, null for no-op
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The interface documents that gc() returns bool|null with "null for no-op", but this is inconsistent with typical PHP interface design where methods should have predictable return types. Consider standardizing gc() to always return bool, where true indicates successful cleanup (or successful no-op) and false indicates failure. This would make the API more predictable and consistent with the other interface methods.

Suggested change
* @return bool|null True on success, false on failure, null for no-op
* @return bool True on success (including no-op when there is nothing to clean), false on failure

Copilot uses AI. Check for mistakes.
* @access public
*/
public static function delete($key, $config = null)
public static function delete($key, $config = null): bool
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The return type declaration : bool is added to the delete() method signature, but the XoopsCacheEngineInterface interface doesn't specify return type declarations in its method signatures (see lines 598-646). For consistency and to ensure all implementations remain compatible with the interface, consider whether to add return type declarations to the interface methods as well, or remove them from the implementing methods. Note that PHP 7.4 supports return type declarations, so this could be done safely for the target PHP version.

Copilot uses AI. Check for mistakes.
Copy link

@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.

Caution

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

⚠️ Outside diff range comments (1)
htdocs/class/cache/xoopscache.php (1)

446-476: ⚠️ Potential issue | 🟠 Major

Cast engine delete result to bool to satisfy the return type. XoopsCache::delete() declares : bool but returns the engine's raw result. Both write() (line 401) and clear() (line 504) cast their engine calls to bool, but delete() does not. This inconsistency can trigger a TypeError if any engine returns null or a non-bool value. Apply the same pattern as the other static methods.

Proposed fix
-        $success = $_this->engine[$engine]->delete($key);
+        $success = (bool) $_this->engine[$engine]->delete($key);

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +223 to +227
$engine = (!empty($settings['engine']) && is_string($settings['engine']))
? $settings['engine']
: 'file';

// Normalize: ensure the resolved engine name is stored in settings
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

Here $engine is derived from the (possibly merged/overridden) $settings, but later config() returns $settings from $_this->configs[$name]. If config() is called with overrides while $name is already the active config, the method can return an {engine, settings} pair that is inconsistent (engine reflects the overrides, settings reflects the stored config) and the overrides may never be applied. Consider ensuring the returned $settings corresponds to the resolved $engine (e.g., return the merged $settings and/or reinitialize/update the config even when $name === $_this->name).

Copilot uses AI. Check for mistakes.
* Read a key from the cache
*
* @param string $key Identifier for the data
* @return mixed The cached data, or false if the data doesn't exist or has expired
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The interface doc for read() states it returns false when data doesn't exist/expired, but at least one built-in engine (XoopsCacheModel::read()) returns null on a cache miss. To avoid misleading implementers and callers, either update the interface contract/docs to allow null (and/or other engine-specific miss values) or normalize built-in engines to consistently return false on a miss.

Suggested change
* @return mixed The cached data, or false if the data doesn't exist or has expired
* @return mixed The cached data, or a falsy value (such as false or null) if the data
* doesn't exist or has expired. Implementations MUST NOT throw on cache
* miss; callers SHOULD treat any falsy return value as a cache miss.

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
3.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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