Files
api/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheRuntimeBehaviorTest.php
T
Jeppe B 2a6a86c9c3 Resolve backend Qodana critical and high findings (#314)
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
2026-07-17 05:44:16 +02:00

355 lines
11 KiB
PHP

<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
class DepartmentWeatherRedisClientFake
{
/** @var array<string,array<string,float>> */
public array $zsets = [];
public function reset(): void
{
$this->zsets = [];
}
public function zadd(string $key, int|float|string $score, string $member): void
{
if (!isset($this->zsets[$key])) {
$this->zsets[$key] = [];
}
$this->zsets[$key][$member] = (float)$score;
}
public function zrevrange(string $key, int $start, int $stop): array
{
$members = $this->zsets[$key] ?? [];
if ($members === []) {
return [];
}
arsort($members, SORT_NUMERIC);
$member_ids = array_keys($members);
if ($stop < 0) {
$stop = count($member_ids) + $stop;
}
$length = max(0, $stop - $start + 1);
if ($length === 0) {
return [];
}
return array_values(array_slice($member_ids, $start, $length));
}
public function zremrangebyscore(string $key, int|float|string $min, int|float|string $max): int
{
$members = $this->zsets[$key] ?? [];
if ($members === []) {
return 0;
}
$min_score = $this->toScoreBoundary($min, true);
$max_score = $this->toScoreBoundary($max, false);
$removed = 0;
foreach ($members as $member => $score) {
if ($score >= $min_score && $score <= $max_score) {
unset($members[$member]);
$removed++;
}
}
if ($members === []) {
unset($this->zsets[$key]);
} else {
$this->zsets[$key] = $members;
}
return $removed;
}
public function zrem(string $key, string $member): int
{
if (!isset($this->zsets[$key][$member])) {
return 0;
}
unset($this->zsets[$key][$member]);
if ($this->zsets[$key] === []) {
unset($this->zsets[$key]);
}
return 1;
}
private function toScoreBoundary(int|float|string $value, bool $is_min): float
{
if (is_string($value)) {
$trimmed = strtolower(trim($value));
if ($trimmed === '-inf') {
return -INF;
}
if ($trimmed === '+inf' || $trimmed === 'inf') {
return INF;
}
}
$numeric = (float)$value;
if (!is_finite($numeric)) {
return $is_min ? -INF : INF;
}
return $numeric;
}
}
class DepartmentWeatherRedisFake
{
/** @var array<string,string> */
public array $store = [];
/** @var array<int,array{key:string,ttl:int,value:string}> */
public array $setExCalls = [];
/** @var array<int,array{key:string,value:string,ttl:int}> */
public array $lockCalls = [];
public bool $lockResult = true;
private DepartmentWeatherRedisClientFake $client;
public function __construct()
{
$this->client = new DepartmentWeatherRedisClientFake();
}
public function reset(): void
{
$this->store = [];
$this->setExCalls = [];
$this->lockCalls = [];
$this->lockResult = true;
$this->client->reset();
}
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): self
{
$this->store[$key] = $value;
$this->setExCalls[] = [
'key' => $key,
'ttl' => $ttl,
'value' => $value,
];
return $this;
}
public function set_if_absent_with_expiration(string $key, string $value, int $ttl): bool
{
$this->lockCalls[] = [
'key' => $key,
'value' => $value,
'ttl' => $ttl,
];
if (!$this->lockResult) {
return false;
}
if (array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
return true;
}
public function get_client(): DepartmentWeatherRedisClientFake
{
return $this->client;
}
}
function department_weather_runtime_redis(): DepartmentWeatherRedisFake
{
if (!defined('redis')) {
define('redis', new DepartmentWeatherRedisFake());
}
/** @var DepartmentWeatherRedisFake $redis */
$redis = redis;
return $redis;
}
function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
return $target->invokeArgs($route, $args);
}
beforeEach(function (): void {
$_SERVER['REQUEST_URI'] = '/departments/weather';
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
$this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
$this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
department_weather_runtime_redis()->reset();
});
afterEach(function (): void {
if ($this->oldTtl === false) {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
}
if ($this->oldStaleTtl === false) {
putenv('DEPARTMENTS_WEATHER_STALE_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl);
}
if ($this->oldHotTtl === false) {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl);
}
});
it('builds department weather preload targets in cli without a request uri', function (): void {
unset($_SERVER['REQUEST_URI']);
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);
expect($targets)->toBe([]);
});
it('serves fresh cached payloads without invoking the resolver', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 5,
'timeline' => [['source' => 'cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
expect($result)->toBe([['source' => 'cache']]);
expect($calls)->toBe(0);
});
it('serves stale cached payloads and enqueues a refresh signal', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 120,
'timeline' => [['source' => 'stale-cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);
expect($result)->toBe([['source' => 'stale-cache']]);
expect($calls)->toBe(0);
expect(department_weather_runtime_redis()->lockCalls)->toHaveCount(1);
expect($targets)->not->toBeEmpty();
});
it('recomputes and rewrites cache payloads when stale window is exceeded', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 400,
'timeline' => [['source' => 'expired-cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
$cacheWriteFound = false;
foreach (department_weather_runtime_redis()->setExCalls as $call) {
if ($call['key'] === $cacheKey && $call['ttl'] === 300) {
$cacheWriteFound = true;
break;
}
}
expect($result)->toBe([['source' => 'resolver']]);
expect($calls)->toBe(1);
expect($cacheWriteFound)->toBeTrue();
});
it('records hot keys order-insensitively and applies preload target caps', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[5, 1, 3], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[1, 3, 5], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[8], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[9], $range]);
$hotSet = department_weather_runtime_redis()->get_client()->zsets['departments_weather:hot_activity:v1'] ?? [];
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(2, 900);
expect($hotSet)->toHaveCount(3);
expect($targets)->toHaveCount(2);
});