Files
Adam Daley c77ba2ddd0 Fix cache-backend prune crash and stale-cache-on-switch bugs (#4222)
* Fix cache-backend prune crash and stale-cache-on-switch bugs

Two real bugs were found by Codex review on FOSSBilling/FOSSBilling#4221
(a backport of the Redis/Memcached cache backend from #4202 to
0.8-next) that are also present on main.

1. onBeforeAdminCronRun() unconditionally called $cache->prune(),
   which only FilesystemAdapter implements. Since #4202 lets
   $di['cache'] be a RedisAdapter/MemcachedAdapter, that raised an
   undefined-method Error - not caught by the surrounding
   catch (\Exception), since Error doesn't extend Exception - aborting
   the admin cron before other scheduled tasks (billing, maintenance,
   etc.) ran for anyone using Redis or Memcached. Now guarded behind a
   PruneableInterface check.

2. Config::setConfig() writes the new config to disk before flushing
   the cache, and CacheFactory::clearAll()/clearNamespace() both read
   the *live* config when called with no arguments - which by then is
   already the new driver/host/database. So update_cache_settings()
   switching an admin from e.g. Redis database 0 to Redis database 1
   (or Redis to filesystem) only ever cleared the newly configured
   backend; entries left in the previous one were never touched and
   could reappear if the admin switched back. Both clearAll() and
   clearNamespace() now accept an optional explicit $cacheConfig, and
   update_cache_settings() captures the outgoing config before the
   switch and explicitly clears it afterwards (both the general
   namespaces and the Doctrine metadata cache namespace), mirroring
   what Config::setConfig() already does for the new config.

Both fixes are best-effort, matching the surrounding code's posture -
a failure clearing the old backend must not throw or block saving the
new config.

* Move hasRedisExtension() into shared Pest bootstrap

AdminTest.php relied on hasRedisExtension() being defined by
CacheFactoryTest.php, which only works because Pest happens to load
that file first when the whole suite runs. Moving it into
tests/Pest.php's shared bootstrap makes it available to any test file
regardless of load order.

Addresses a CodeRabbit review comment on FOSSBilling/FOSSBilling#4222.
2026-08-27 16:58:19 +01:00

245 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific
| PHPUnit test case class. By default, that class is "PHPUnit\Framework\TestCase".
|
| You can define custom test case classes in this file and use them via the
| `uses()` function.
|
*/
// Global test configuration
putenv('APP_ENV=test');
define('PATH_TESTS', __DIR__);
// Bump the memory limit. The strict-variables render-everything harness
// parses every FOSSBilling template and allocates many stubs and AST nodes,
// which exceeds the default 128M limit and causes the pest process to exit
// with 255 mid-suite. 2G is comfortably above peak usage (~185M).
ini_set('memory_limit', '2G');
// Disable DEBUG mode to prevent FOSSBilling\Exception from logging stack traces
// Must be defined BEFORE load.php is included
if (!defined('DEBUG')) {
define('DEBUG', false);
}
// Pre-declare translation functions before the application bootstrap loads them.
// These stubs will be used if the full translation system isn't initialized
if (!function_exists('__trans')) {
function __trans(string $msgid, ?array $values = null): string
{
return $values ? strtr($msgid, $values) : $msgid;
}
}
if (!function_exists('__pluralTrans')) {
function __pluralTrans(string $msgid, string $msgidPlural, int $number, ?array $values = null): string
{
return $values ? strtr($msgid, $values) : $msgid;
}
}
// Load application bootstrap
require_once __DIR__ . '/../src/load.php';
require_once __DIR__ . '/../src/vendor/autoload.php';
// Load test helpers
require_once __DIR__ . '/Helpers/Container.php';
require_once __DIR__ . '/Helpers/Factories.php';
require_once __DIR__ . '/Helpers/Api.php';
require_once __DIR__ . '/Support/CombinedTwigLoader.php';
require_once __DIR__ . '/Support/PermissiveStub.php';
require_once __DIR__ . '/Support/PermissiveContainer.php';
require_once __DIR__ . '/Support/VariableCollectorVisitor.php';
require_once __DIR__ . '/Support/StrictTemplateRenderer.php';
require_once __DIR__ . '/Support/TwigAstLinter.php';
require_once __DIR__ . '/Support/ToApiArrayAuditor.php';
// Load test datasets
require_once __DIR__ . '/Datasets/PeriodCodes.php';
require_once __DIR__ . '/Datasets/ValidationData.php';
require_once __DIR__ . '/Datasets/GeographicData.php';
/**
* Whether a Redis client extension is available. Cache-backend tests that need an
* environment without it (to exercise the connection-failure/fallback paths) skip
* themselves when this is true.
*/
function hasRedisExtension(): bool
{
return class_exists(Redis::class) || class_exists(Relay\Relay::class) || class_exists(RedisCluster::class);
}
/**
* Construct an API endpoint with the default test container.
*
* @template T of FOSSBilling\Api\AbstractApi
*
* @param T $api
*
* @return T
*/
function apiEndpoint(FOSSBilling\Api\AbstractApi $api): FOSSBilling\Api\AbstractApi
{
$api->setDi(Tests\Helpers\container());
return $api;
}
/**
* Runs $callback with APP_ENV set to $value (or unset entirely when $value is null),
* restoring the previous value afterward regardless of outcome.
*/
function withAppEnv(?string $value, callable $callback): mixed
{
$previous = getenv('APP_ENV');
if ($value === null) {
putenv('APP_ENV');
} else {
putenv('APP_ENV=' . $value);
}
try {
return $callback();
} finally {
putenv($previous === false ? 'APP_ENV' : 'APP_ENV=' . $previous);
}
}
// Define TestLogger class after autoloader is registered.
// This is deferred because FOSSBilling\Logger is loaded via the autoloader.
// @phpstan-ignore-next-line
if (!class_exists(Tests\Helpers\TestLogger::class)) {
// @phpstan-ignore-next-line
eval('
namespace Tests\Helpers;
class TestLogger extends \Psr\Log\AbstractLogger
{
public array $calls = [];
private string $channel = "application";
private array $context = [];
public function __construct()
{
$this->calls = [];
}
public function log($level, string|\\Stringable $message, array $context = []): void
{
$effectiveContext = [...$this->context, ...$context];
$params = [$message];
if ($effectiveContext !== []) {
$params[] = $effectiveContext;
}
$call = ["method" => $level, "params" => $params];
if ($this->channel !== "application") {
$call["channel"] = $this->channel;
}
$this->calls[] = $call;
}
public function withChannel(string $channel): static
{
$logger = clone $this;
$logger->calls =& $this->calls;
$logger->channel = $channel;
return $logger;
}
public function withContext(array $context): static
{
$logger = clone $this;
$logger->calls =& $this->calls;
$logger->context = [...$this->context, ...$context];
return $logger;
}
}
');
}
// Redirect error_log to /dev/null during tests to prevent "PHPUnit controlled exception" clutter
ini_set('error_log', '/dev/null');
// Configure Unit tests base with Mockery integration
uses(MockeryPHPUnitIntegration::class)
->beforeEach(function (): void {
// Unit test setup
})
->in('Unit');
// Configure E2E tests - requires live instance with API access
// Run with: ./vendor/bin/pest --testsuite=E2E
// Requires APP_URL and TEST_API_KEY environment variables
$appUrl = getenv('APP_URL');
$testApiKey = getenv('TEST_API_KEY');
if ($appUrl && $testApiKey) {
uses()
->beforeEach(function () use ($appUrl, $testApiKey): void {
Tests\Helpers\ApiClient::setBaseUrl($appUrl);
Tests\Helpers\ApiClient::setApiKey($testApiKey);
})
->in('E2E');
}
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain
| conditions. The "expect()" function gives you access to a set of useful
| expectation methods.
|
*/
expect()->extend('toBeDomain', function (): void {
$this->not->toBeEmpty();
expect($this->value)->toBeString();
expect(filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))->not->toBeFalse();
});
expect()->extend('toBeEmail', function (): void {
$this->not->toBeEmpty();
expect($this->value)->toBeString();
expect(filter_var($this->value, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
});
expect()->extend('toBeUrl', function (): void {
$this->not->toBeEmpty();
expect($this->value)->toBeString();
expect(filter_var($this->value, FILTER_VALIDATE_URL))->not->toBeFalse();
});
expect()->extend('toBeUuid', function (): void {
$this->not->toBeEmpty();
expect($this->value)->toBeString();
expect(preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $this->value))->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code
| specific to your project that you don't want to repeat in every file.
| Here you can define custom helper functions and expect() extensions.
|
*/