store[$key] ?? null; } public function set(string $key, string $value): void { $this->store[$key] = $value; } public function setEx(string $key, string $value, int $ttl): void { $this->store[$key] = $value; } public function delete(string $key): void { unset($this->store[$key]); } public function expire(string $key, int $ttl): void { // TTL is not simulated in this test adapter. } public function set_array(string $key, array $value): void { $this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE); } public function get_array(string $key): ?array { $value = $this->store[$key] ?? null; if (!is_string($value)) { return null; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : null; } public function clear_keys(string $pattern): void { $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; foreach (array_keys($this->store) as $key) { if (preg_match($regex, $key)) { unset($this->store[$key]); } } } } } beforeEach(function (): void { system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter()); }); afterEach(function (): void { system_search_cache::setAdapterForTests(null); }); it('reuses parser cache entries for repeated natural-language intent requests', function (): void { $calls = 0; $parser = new system_search_openai_intent_parser( function (array $payload, string $apiKey) use (&$calls): array { $calls++; return [ 'output' => [ [ 'content' => [ [ 'text' => json_encode([ 'success' => true, 'normalized_query' => 'acme unpaid invoices', 'aliases' => ['acme'], 'entity_hints' => ['invoices'], 'confidence' => 0.91, 'association_hint' => true, ], JSON_UNESCAPED_UNICODE), ], ], ], ], ]; }, true, 'test-key' ); $first = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']); $second = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']); expect($first['source'])->toBe('openai'); expect($second['source'])->toBe('cache'); expect($calls)->toBe(1); }); it('clears parser cache namespace via clearAll to force a fresh parse', function (): void { $calls = 0; $parser = new system_search_openai_intent_parser( function (array $payload, string $apiKey) use (&$calls): array { $calls++; return [ 'output' => [ [ 'content' => [ [ 'text' => json_encode([ 'success' => true, 'normalized_query' => 'acme invoices', 'aliases' => ['acme'], 'entity_hints' => ['invoices'], 'confidence' => 0.8, 'association_hint' => false, ], JSON_UNESCAPED_UNICODE), ], ], ], ], ]; }, true, 'test-key' ); $parser->parse('acme invoices', ['invoices']); system_search_cache::clearAll(); $result = $parser->parse('acme invoices', ['invoices']); expect($result['source'])->toBe('openai'); expect($calls)->toBe(2); }); it('invalidates query cache immediately when dirty-table marker is registered', function (): void { $hash = md5('test-query'); system_search_cache::setQuery($hash, ['results' => [], 'grouped_results' => [], 'meta' => []], 120); expect(system_search_cache::getQuery($hash))->not->toBeNull(); system_search_cache::markDirtyTable('orders'); $dirty = system_search_cache::consumeDirtyTables(); expect(system_search_cache::getQuery($hash))->toBeNull(); expect($dirty)->toContain('orders'); });