store = []; } public function get(string $key): mixed { return $this->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 unit tests. } 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('redacts obvious sensitive fragments before sending query to intent parser', function (): void { $query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678, order 987654, reg AB12345, id 550e8400-e29b-41d4-a716-446655440000'; $redacted = system_search_openai_intent_parser::redactSensitiveQuery($query); expect($redacted)->toContain('[email]'); expect($redacted)->toContain('[phone]'); expect($redacted)->toContain('[cvr]'); expect($redacted)->toContain('order [id]'); expect($redacted)->toContain('[plate]'); expect($redacted)->toContain('[uuid]'); expect($redacted)->not->toContain('alice@example.com'); expect($redacted)->not->toContain('12345678'); expect($redacted)->not->toContain('987654'); expect($redacted)->not->toContain('AB12345'); }); it('builds payload with redacted query and parses strict JSON output', function (): void { $capturedPrompt = ''; $parser = new system_search_openai_intent_parser( function (array $payload, string $apiKey) use (&$capturedPrompt): array { $capturedPrompt = (string)($payload['input'][0]['content'][0]['text'] ?? ''); return [ 'output' => [ [ 'content' => [ [ 'text' => json_encode([ 'success' => true, 'normalized_query' => 'acme unpaid invoices', 'aliases' => ['acme', 'invoice overdue'], 'entity_hints' => ['customers', 'invoices'], 'confidence' => 0.93, 'association_hint' => true, ], JSON_UNESCAPED_UNICODE), ], ], ], ], ]; }, true, 'test-key' ); $result = $parser->parse( 'find unpaid invoices for alice@example.com', ['customers', 'invoices'], ['customers' => ['account'], 'invoices' => ['billing']] ); expect($capturedPrompt)->toContain('[email]'); expect($capturedPrompt)->not->toContain('alice@example.com'); expect($result['success'])->toBeTrue(); expect($result['source'])->toBe('openai'); expect($result['confidence'])->toBe(0.93); expect($result['entity_hints'])->toBe(['customers', 'invoices']); }); it('falls back safely when OpenAI is disabled', function (): void { $parser = new system_search_openai_intent_parser(null, false, null); $result = $parser->parse('find acme invoices', ['customers', 'invoices']); expect($result['success'])->toBeFalse(); expect($result['source'])->toBe('none'); expect($result['fallback_reason'])->toBe('openai_disabled'); }); it('handles malformed OpenAI response payloads without throwing', function (): void { $parser = new system_search_openai_intent_parser( function (array $payload, string $apiKey): array { return ['output' => []]; }, true, 'test-key' ); $result = $parser->parse('find acme invoices', ['invoices']); expect($result['success'])->toBeFalse(); expect($result['source'])->toBe('openai'); expect((string)$result['fallback_reason'])->toContain('Invalid response format'); }); it('uses parser cache for identical query and allowed type combinations', 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' ); $first = $parser->parse('acme invoices', ['invoices']); $second = $parser->parse('acme invoices', ['invoices']); expect($first['source'])->toBe('openai'); expect($second['source'])->toBe('cache'); expect($calls)->toBe(1); }); it('caps alias and hint payloads from OpenAI and filters hints to allowed types', function (): void { $manyAliases = []; for ($i = 0; $i < 40; $i++) { $manyAliases[] = 'ALIAS_' . $i . '_' . str_repeat('x', 90); } $parser = new system_search_openai_intent_parser( function (array $payload, string $apiKey) use ($manyAliases): array { return [ 'output' => [ [ 'content' => [ [ 'text' => json_encode([ 'success' => true, 'normalized_query' => str_repeat('q', 500), 'aliases' => $manyAliases, 'entity_hints' => ['orders', 'invoices', 'made_up_type'], 'confidence' => 0.7, 'association_hint' => false, 'fallback_reason' => null, ], JSON_UNESCAPED_UNICODE), ], ], ], ], ]; }, true, 'test-key' ); $result = $parser->parse('acme', ['orders', 'invoices']); expect(count($result['aliases']))->toBeLessThanOrEqual(12); $longestAlias = 0; foreach ($result['aliases'] as $alias) { $longestAlias = max($longestAlias, mb_strlen((string)$alias)); } expect($longestAlias)->toBeLessThanOrEqual(64); expect(mb_strlen((string)$result['normalized_query']))->toBeLessThanOrEqual(256); expect($result['entity_hints'])->toBe(['orders', 'invoices']); });