962 lines
34 KiB
PHP
962 lines
34 KiB
PHP
<?php
|
|
|
|
app_require('interfaces/system_search_intent_parser_i.php');
|
|
app_require('classes/system_search_cache.php');
|
|
app_require('classes/system_search_document_index.php');
|
|
app_require('classes/system_search_economic_customer_index.php');
|
|
app_require('classes/system_search_openai_intent_parser.php');
|
|
app_require('classes/system_search_registry.php');
|
|
app_require('classes/system_search_service.php');
|
|
|
|
use classes\system_search_cache;
|
|
use classes\system_search_economic_customer_index;
|
|
use classes\system_search_service;
|
|
use interfaces\system_search_intent_parser_i;
|
|
|
|
if (!class_exists('SystemSearchTestRedisAdapter')) {
|
|
class SystemSearchTestRedisAdapter
|
|
{
|
|
private array $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]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('FakeSystemSearchIntentParser')) {
|
|
class FakeSystemSearchIntentParser implements system_search_intent_parser_i
|
|
{
|
|
public int $calls = 0;
|
|
/**
|
|
* @var array<int, array<string, mixed>>
|
|
*/
|
|
public array $responses = [];
|
|
|
|
public function __construct(array $responses = [])
|
|
{
|
|
$this->responses = $responses;
|
|
}
|
|
|
|
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
|
|
{
|
|
$this->calls++;
|
|
if (empty($this->responses)) {
|
|
return [
|
|
'success' => false,
|
|
'normalized_query' => '',
|
|
'aliases' => [],
|
|
'entity_hints' => [],
|
|
'confidence' => 0.0,
|
|
'association_hint' => false,
|
|
'fallback_reason' => 'no_response',
|
|
'source' => 'none',
|
|
];
|
|
}
|
|
return array_shift($this->responses);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('TestableSystemSearchService')) {
|
|
class TestableSystemSearchService extends system_search_service
|
|
{
|
|
/**
|
|
* @var array<int, array<string, mixed>>
|
|
*/
|
|
public array $lexicalCalls = [];
|
|
/**
|
|
* @var array<int, array<int, array<string, mixed>>>
|
|
*/
|
|
private array $queuedLexicalResults;
|
|
|
|
public function __construct(system_search_intent_parser_i $intentParser, array $queuedLexicalResults)
|
|
{
|
|
$this->queuedLexicalResults = $queuedLexicalResults;
|
|
parent::__construct($intentParser);
|
|
}
|
|
|
|
protected function executeLexicalSearch(
|
|
array $activeTypes,
|
|
array $terms,
|
|
array $entityBoost,
|
|
array $ownOnlyTypes,
|
|
?int $ownCustomerNumber,
|
|
array $permissionsCatalogAll,
|
|
array $permissionsCatalogOwn,
|
|
array $moduleConfigVisibility,
|
|
array $forcedCustomerNumbers = []
|
|
): array {
|
|
$this->lexicalCalls[] = [
|
|
'activeTypes' => $activeTypes,
|
|
'terms' => $terms,
|
|
'entityBoost' => $entityBoost,
|
|
'ownOnlyTypes' => $ownOnlyTypes,
|
|
'ownCustomerNumber' => $ownCustomerNumber,
|
|
'forcedCustomerNumbers' => $forcedCustomerNumbers,
|
|
];
|
|
if (empty($this->queuedLexicalResults)) {
|
|
return [];
|
|
}
|
|
return array_shift($this->queuedLexicalResults);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('CustomerContextAwareTestableSystemSearchService')) {
|
|
class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService
|
|
{
|
|
/**
|
|
* @var array<int, array<string, mixed>>
|
|
*/
|
|
public array $customerContexts = [];
|
|
|
|
protected function loadCustomerContexts(array $customerNumbers): array
|
|
{
|
|
$contexts = [];
|
|
foreach ($customerNumbers as $customerNumber) {
|
|
$normalized = (int)$customerNumber;
|
|
if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) {
|
|
continue;
|
|
}
|
|
$contexts[$normalized] = $this->customerContexts[$normalized];
|
|
}
|
|
return $contexts;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!function_exists('system_search_service_invoke_private')) {
|
|
function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
|
|
{
|
|
$reflection = new ReflectionMethod($instance, $method);
|
|
$reflection->setAccessible(true);
|
|
return $reflection->invokeArgs($instance, $args);
|
|
}
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
system_search_cache::setAdapterForTests(null);
|
|
});
|
|
|
|
it('does not invoke intent parser when lexical confidence is already high', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => 'ignored',
|
|
'aliases' => ['ignored'],
|
|
'entity_hints' => ['orders'],
|
|
'confidence' => 0.99,
|
|
'association_hint' => false,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 95],
|
|
['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 90],
|
|
['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 88],
|
|
['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 84],
|
|
['entity_type' => 'orders', 'entity_id' => '5', 'title' => 'Order #5', 'score' => 80],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'order 1',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(0);
|
|
expect($result['meta']['intent_parser']['status'])->toBe('skipped');
|
|
expect(count($service->lexicalCalls))->toBe(1);
|
|
});
|
|
|
|
it('invokes parser on low-confidence lexical results and applies hints-only boosts', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => 'acme invoices',
|
|
'aliases' => ['acme corp', 'invoice overdue'],
|
|
'entity_hints' => ['orders'],
|
|
'confidence' => 0.87,
|
|
'association_hint' => true,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 20]],
|
|
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 85]],
|
|
]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'acm inv',
|
|
'allowed_types' => ['orders', 'customers'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(1);
|
|
expect(count($service->lexicalCalls))->toBe(2);
|
|
expect($service->lexicalCalls[1]['entityBoost']['orders'] ?? 0)->toBe(25);
|
|
expect(implode(' ', $service->lexicalCalls[1]['terms']))->toContain('acme');
|
|
expect($result['meta']['intent_parser']['status'])->toBe('ok');
|
|
expect($result['meta']['intent_parser']['source'])->toBe('openai');
|
|
});
|
|
|
|
it('never lets parser entity hints override explicit include filters', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => 'customer acme',
|
|
'aliases' => ['acme'],
|
|
'entity_hints' => ['orders'],
|
|
'confidence' => 0.7,
|
|
'association_hint' => true,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 10]],
|
|
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 90]],
|
|
]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'acm',
|
|
'include_types' => ['customers'],
|
|
'allowed_types' => ['customers', 'orders'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(1);
|
|
expect($service->lexicalCalls[0]['activeTypes'])->toBe(['customers']);
|
|
expect($service->lexicalCalls[1]['activeTypes'])->toBe(['customers']);
|
|
expect($result['results'][0]['entity_type'])->toBe('customers');
|
|
});
|
|
|
|
it('returns fallback parser metadata when parser fails gracefully', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => false,
|
|
'normalized_query' => '',
|
|
'aliases' => [],
|
|
'entity_hints' => [],
|
|
'confidence' => 0.0,
|
|
'association_hint' => false,
|
|
'fallback_reason' => 'openai_disabled',
|
|
'source' => 'none',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
|
|
]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'unknown phrase',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(1);
|
|
expect($result['meta']['intent_parser']['status'])->toBe('fallback');
|
|
expect($result['meta']['intent_parser']['fallback_reason'])->toBe('openai_disabled');
|
|
});
|
|
|
|
it('scopes query cache by permission context to avoid cross-user cache leakage', function (): void {
|
|
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
|
|
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]],
|
|
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]],
|
|
]);
|
|
|
|
$first = $service->search([
|
|
'query' => 'acme',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
'permissions_catalog_own' => ['list_orders'],
|
|
'module_config_visibility' => ['openAI' => true],
|
|
]);
|
|
$second = $service->search([
|
|
'query' => 'acme',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
'permissions_catalog_own' => ['list_own_orders'],
|
|
'module_config_visibility' => ['openAI' => false],
|
|
]);
|
|
|
|
expect(count($service->lexicalCalls))->toBe(2);
|
|
expect($first['results'][0]['entity_id'])->toBe('1');
|
|
expect($second['results'][0]['entity_id'])->toBe('2');
|
|
});
|
|
|
|
it('caps AI-driven expanded terms to prevent query amplification', function (): void {
|
|
$aliases = [];
|
|
for ($i = 0; $i < 80; $i++) {
|
|
$aliases[] = 'alias_' . $i . '_' . str_repeat('x', 90);
|
|
}
|
|
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => str_repeat('n', 500),
|
|
'aliases' => $aliases,
|
|
'entity_hints' => ['orders'],
|
|
'confidence' => 0.8,
|
|
'association_hint' => false,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
|
|
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 80]],
|
|
]);
|
|
|
|
$service->search([
|
|
'query' => 'a b',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
$expanded = $service->lexicalCalls[1]['terms'] ?? [];
|
|
expect(count($expanded))->toBeLessThanOrEqual(24);
|
|
$maxLen = 0;
|
|
foreach ($expanded as $term) {
|
|
$maxLen = max($maxLen, mb_strlen((string)$term));
|
|
}
|
|
expect($maxLen)->toBeLessThanOrEqual(64);
|
|
});
|
|
|
|
it('does not expand associations for own-only entity types', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[[
|
|
'entity_type' => 'customers',
|
|
'entity_id' => '10',
|
|
'title' => 'Acme',
|
|
'customer_number' => 1234,
|
|
'score' => 80,
|
|
]],
|
|
[],
|
|
]);
|
|
|
|
$service->search([
|
|
'query' => 'acme',
|
|
'allowed_types' => ['customers', 'orders'],
|
|
'own_only_types' => ['orders'],
|
|
'own_customer_number' => 4444,
|
|
'include_associations' => true,
|
|
]);
|
|
|
|
expect(count($service->lexicalCalls))->toBe(1);
|
|
});
|
|
|
|
it('expands danish discount wording into lexical discount synonyms', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86],
|
|
]]);
|
|
|
|
$service->search([
|
|
'query' => 'pleno rabat',
|
|
'allowed_types' => ['customer_discounts'],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
$terms = $service->lexicalCalls[0]['terms'] ?? [];
|
|
expect($parser->calls)->toBeGreaterThanOrEqual(1);
|
|
expect($terms)->toContain('rabat');
|
|
expect($terms)->toContain('discount');
|
|
});
|
|
|
|
it('invokes parser for intent-driven natural-language queries even when lexical score is high', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => 'acme customer discount',
|
|
'aliases' => ['discount', 'price override'],
|
|
'entity_hints' => ['customer_discounts'],
|
|
'confidence' => 0.82,
|
|
'association_hint' => true,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88],
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86],
|
|
],
|
|
[
|
|
['entity_type' => 'customer_discounts', 'entity_id' => '10', 'title' => 'improved', 'score' => 97],
|
|
],
|
|
]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'show me acme rabat options',
|
|
'allowed_types' => ['customer_discounts'],
|
|
'include_associations' => false,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(1);
|
|
expect(count($service->lexicalCalls))->toBe(2);
|
|
expect($result['meta']['intent_parser']['status'])->toBe('ok');
|
|
});
|
|
|
|
it('uses association hints to pull related customer records from non-customer matches', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser([
|
|
[
|
|
'success' => true,
|
|
'normalized_query' => 'pleno customer discount',
|
|
'aliases' => ['discount'],
|
|
'entity_hints' => ['customer_discounts', 'orders'],
|
|
'confidence' => 0.85,
|
|
'association_hint' => true,
|
|
'fallback_reason' => null,
|
|
'source' => 'openai',
|
|
],
|
|
]);
|
|
|
|
$service = new TestableSystemSearchService($parser, [
|
|
[
|
|
[
|
|
'entity_type' => 'customer_discounts',
|
|
'entity_id' => '44',
|
|
'title' => 'Discount #44',
|
|
'customer_number' => 777,
|
|
'score' => 12,
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'entity_type' => 'customer_discounts',
|
|
'entity_id' => '44',
|
|
'title' => 'Discount #44',
|
|
'customer_number' => 777,
|
|
'score' => 95,
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'entity_type' => 'orders',
|
|
'entity_id' => '9001',
|
|
'title' => 'Order #9001',
|
|
'customer_number' => 777,
|
|
'score' => 40,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'pleno rabat',
|
|
'allowed_types' => ['customer_discounts', 'orders'],
|
|
'include_associations' => true,
|
|
'debug_intent' => true,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(1);
|
|
expect(count($service->lexicalCalls))->toBe(3);
|
|
expect($service->lexicalCalls[2]['forcedCustomerNumbers'])->toBe([777]);
|
|
|
|
$types = array_map(static fn(array $row) => (string)$row['entity_type'], $result['results']);
|
|
expect($types)->toContain('orders');
|
|
expect($result['meta']['intent_parser']['status'])->toBe('ok');
|
|
});
|
|
|
|
it('prefers newer records when relevance scores are comparable', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
[
|
|
'entity_type' => 'bookings',
|
|
'entity_id' => '1',
|
|
'title' => 'Older booking',
|
|
'score' => 90,
|
|
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'bookings',
|
|
'entity_id' => '2',
|
|
'title' => 'Newer booking',
|
|
'score' => 89,
|
|
'payload' => ['updated_at' => '2026-03-10 12:00:00'],
|
|
],
|
|
['entity_type' => 'bookings', 'entity_id' => '3', 'title' => 'B3', 'score' => 85],
|
|
['entity_type' => 'bookings', 'entity_id' => '4', 'title' => 'B4', 'score' => 84],
|
|
['entity_type' => 'bookings', 'entity_id' => '5', 'title' => 'B5', 'score' => 83],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'booking',
|
|
'allowed_types' => ['bookings'],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(0);
|
|
expect($result['results'][0]['entity_id'])->toBe('2');
|
|
});
|
|
|
|
it('keeps explicit identifier matches ahead of newer but weaker records', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
[
|
|
'entity_type' => 'orders',
|
|
'entity_id' => '100',
|
|
'title' => 'Exact order',
|
|
'score' => 90,
|
|
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'orders',
|
|
'entity_id' => '101',
|
|
'title' => 'Newer but weaker',
|
|
'score' => 89,
|
|
'payload' => ['updated_at' => '2026-03-12 00:00:00'],
|
|
],
|
|
['entity_type' => 'orders', 'entity_id' => '102', 'title' => 'O102', 'score' => 85],
|
|
['entity_type' => 'orders', 'entity_id' => '103', 'title' => 'O103', 'score' => 84],
|
|
['entity_type' => 'orders', 'entity_id' => '104', 'title' => 'O104', 'score' => 83],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => '12345',
|
|
'allowed_types' => ['orders'],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(0);
|
|
expect($result['results'][0]['entity_id'])->toBe('100');
|
|
});
|
|
|
|
it('promotes invoices orders order bookings and customers in ranking', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97],
|
|
['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96],
|
|
['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70],
|
|
['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69],
|
|
['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68],
|
|
['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => '12345',
|
|
'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
$types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
|
|
expect($parser->calls)->toBe(0);
|
|
expect(array_slice($types, 0, 4))->toBe([
|
|
'invoices',
|
|
'orders',
|
|
'order_bookings',
|
|
'customers',
|
|
]);
|
|
});
|
|
|
|
it('never prioritizes cancelled bookings over active bookings', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
[
|
|
'entity_type' => 'bookings',
|
|
'entity_id' => '500',
|
|
'title' => 'Cancelled booking',
|
|
'score' => 99,
|
|
'payload' => [
|
|
'updated_at' => '2026-03-12 12:00:00',
|
|
'status' => 'cancelled',
|
|
],
|
|
],
|
|
[
|
|
'entity_type' => 'bookings',
|
|
'entity_id' => '501',
|
|
'title' => 'Active booking',
|
|
'score' => 80,
|
|
'payload' => [
|
|
'updated_at' => '2026-03-11 12:00:00',
|
|
'status' => 'active',
|
|
],
|
|
],
|
|
['entity_type' => 'bookings', 'entity_id' => '502', 'title' => 'B502', 'score' => 79],
|
|
['entity_type' => 'bookings', 'entity_id' => '503', 'title' => 'B503', 'score' => 78],
|
|
['entity_type' => 'bookings', 'entity_id' => '504', 'title' => 'B504', 'score' => 77],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => 'booking',
|
|
'allowed_types' => ['bookings'],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
expect($parser->calls)->toBe(0);
|
|
expect($result['results'][0]['entity_id'])->toBe('501');
|
|
expect($result['results'][1]['entity_id'])->not->toBe('500');
|
|
});
|
|
|
|
it('heavily demotes configured low-priority entity types in ranking', function (): void {
|
|
$parser = new FakeSystemSearchIntentParser();
|
|
$service = new TestableSystemSearchService($parser, [[
|
|
[
|
|
'entity_type' => 'xlvask_customers',
|
|
'entity_id' => '700',
|
|
'title' => 'XLVask customer',
|
|
'score' => 99,
|
|
'payload' => ['updated_at' => '2026-03-12 10:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'xlvask_usage_logs',
|
|
'entity_id' => '701',
|
|
'title' => 'XLVask usage log',
|
|
'score' => 98,
|
|
'payload' => ['updated_at' => '2026-03-12 11:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'customer_discounts',
|
|
'entity_id' => '702',
|
|
'title' => 'Customer discount',
|
|
'score' => 97,
|
|
'payload' => ['updated_at' => '2026-03-12 12:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'department_selfserve_vehicle_conditions',
|
|
'entity_id' => '703',
|
|
'title' => 'Vehicle condition',
|
|
'score' => 96,
|
|
'payload' => ['updated_at' => '2026-03-12 13:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'permissions',
|
|
'entity_id' => '704',
|
|
'title' => 'Permission #704',
|
|
'score' => 95,
|
|
'payload' => ['updated_at' => '2026-03-12 14:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'branding',
|
|
'entity_id' => '705',
|
|
'title' => 'Branding #705',
|
|
'score' => 94,
|
|
'payload' => ['updated_at' => '2026-03-12 15:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'order_items',
|
|
'entity_id' => '706',
|
|
'title' => 'Order item #706',
|
|
'score' => 93,
|
|
'payload' => ['updated_at' => '2026-03-12 16:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'module_config',
|
|
'entity_id' => '707',
|
|
'title' => 'Module config #707',
|
|
'score' => 92,
|
|
'payload' => ['updated_at' => '2026-03-12 17:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'xlvask_vehicle_types',
|
|
'entity_id' => '710',
|
|
'title' => 'XLVask vehicle type',
|
|
'score' => 91,
|
|
'payload' => ['updated_at' => '2026-03-12 18:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'motorapi_lookups',
|
|
'entity_id' => '711',
|
|
'title' => 'MotorAPI lookup',
|
|
'score' => 90,
|
|
'payload' => ['updated_at' => '2026-03-12 19:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'orders',
|
|
'entity_id' => '708',
|
|
'title' => 'Order #708',
|
|
'score' => 76,
|
|
'payload' => ['updated_at' => '2026-03-01 00:00:00'],
|
|
],
|
|
[
|
|
'entity_type' => 'customers',
|
|
'entity_id' => '709',
|
|
'title' => 'Customer #709',
|
|
'score' => 74,
|
|
'payload' => ['updated_at' => '2026-03-01 00:00:00'],
|
|
],
|
|
]]);
|
|
|
|
$result = $service->search([
|
|
'query' => '12345',
|
|
'allowed_types' => [
|
|
'orders',
|
|
'customers',
|
|
'xlvask_customers',
|
|
'xlvask_usage_logs',
|
|
'customer_discounts',
|
|
'department_selfserve_vehicle_conditions',
|
|
'permissions',
|
|
'branding',
|
|
'order_items',
|
|
'module_config',
|
|
'xlvask_vehicle_types',
|
|
'motorapi_lookups',
|
|
],
|
|
'include_associations' => false,
|
|
]);
|
|
|
|
$types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
|
|
expect($parser->calls)->toBe(0);
|
|
expect($types[0])->toBe('orders');
|
|
expect($types[1])->toBe('customers');
|
|
expect(array_slice($types, 2, 10))->toBe([
|
|
'xlvask_customers',
|
|
'xlvask_usage_logs',
|
|
'customer_discounts',
|
|
'department_selfserve_vehicle_conditions',
|
|
'permissions',
|
|
'branding',
|
|
'order_items',
|
|
'module_config',
|
|
'xlvask_vehicle_types',
|
|
'motorapi_lookups',
|
|
]);
|
|
});
|
|
|
|
it('tokenizes unicode names without stripping non ascii letters', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']);
|
|
|
|
expect($tokens)->toContain('møller');
|
|
expect($tokens)->toContain('århus');
|
|
expect($tokens)->not->toContain('ller');
|
|
expect($tokens)->not->toContain('rhus');
|
|
});
|
|
|
|
it('does not treat explicit identifier queries as intent driven', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$looksIntentDriven = system_search_service_invoke_private(
|
|
$service,
|
|
'queryLooksIntentDriven',
|
|
['order 123456 for acme', ['order', '123456', 'for', 'acme']]
|
|
);
|
|
|
|
expect($looksIntentDriven)->toBeFalse();
|
|
});
|
|
|
|
it('requires broader term coverage for multi word scoring', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$narrowScore = system_search_service_invoke_private(
|
|
$service,
|
|
'scoreRow',
|
|
[['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
|
|
);
|
|
$broadScore = system_search_service_invoke_private(
|
|
$service,
|
|
'scoreRow',
|
|
[['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
|
|
);
|
|
|
|
expect($narrowScore)->toBe(0);
|
|
expect($broadScore)->toBeGreaterThan(0);
|
|
});
|
|
|
|
it('falls back to invoice date ranges when invoice names are missing', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$fullRangeTitle = system_search_service_invoke_private(
|
|
$service,
|
|
'invoiceResultTitle',
|
|
[null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42]
|
|
);
|
|
$fromOnlyTitle = system_search_service_invoke_private(
|
|
$service,
|
|
'invoiceResultTitle',
|
|
['', '2026-02-01 00:00:01', null, 43]
|
|
);
|
|
$fallbackTitle = system_search_service_invoke_private(
|
|
$service,
|
|
'invoiceResultTitle',
|
|
[null, null, null, 44]
|
|
);
|
|
|
|
expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28');
|
|
expect($fromOnlyTitle)->toBe('2026-02-01');
|
|
expect($fallbackTitle)->toBe('Invoice collection #44');
|
|
});
|
|
|
|
it('uses the goal criteria label for department goal titles', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$labeledTitle = system_search_service_invoke_private(
|
|
$service,
|
|
'departmentGoalResultTitle',
|
|
[json_encode(['label' => 'Weekly Wash Goal'], JSON_UNESCAPED_UNICODE), 91, 'Department goals #91']
|
|
);
|
|
$fallbackTitle = system_search_service_invoke_private(
|
|
$service,
|
|
'departmentGoalResultTitle',
|
|
[json_encode(['target' => 12], JSON_UNESCAPED_UNICODE), 92, 'Department goals #92']
|
|
);
|
|
|
|
expect($labeledTitle)->toBe('Weekly Wash Goal');
|
|
expect($fallbackTitle)->toBe('Department goals #92');
|
|
});
|
|
|
|
it('derives xlvask customer numbers only from digits-only extern ids', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']);
|
|
$uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']);
|
|
$mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']);
|
|
$blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', [' ', 'digits_only']);
|
|
|
|
expect($digitsOnly)->toBe(12345679);
|
|
expect($uuidLike)->toBeNull();
|
|
expect($mixed)->toBeNull();
|
|
expect($blank)->toBeNull();
|
|
});
|
|
|
|
it('replaces unnamed user titles with the customer context name', function (): void {
|
|
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
$service->customerContexts = [
|
|
777 => [
|
|
'customer_number' => 777,
|
|
'name' => 'Acme Transport',
|
|
'barred' => false,
|
|
'status' => 'active',
|
|
],
|
|
];
|
|
|
|
$result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[
|
|
'entity_type' => 'users',
|
|
'entity_id' => '55',
|
|
'title' => 'unnamed',
|
|
'description' => '',
|
|
'customer_number' => 777,
|
|
'payload' => [
|
|
'id' => 55,
|
|
'display_name' => 'unnamed',
|
|
],
|
|
]]);
|
|
|
|
expect($result['title'])->toBe('Acme Transport');
|
|
expect($result['customer_name'])->toBe('Acme Transport');
|
|
});
|
|
|
|
it('enriches object attachment results with associated customer context', function (): void {
|
|
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
$service->customerContexts = [
|
|
777 => [
|
|
'customer_number' => 777,
|
|
'user_id' => 55,
|
|
'name' => 'Acme Transport',
|
|
'barred' => true,
|
|
'status' => 'barred',
|
|
'email' => 'dispatch@acme.test',
|
|
'phone' => '40112233',
|
|
'cvr' => '12345678',
|
|
'address' => 'Road 1',
|
|
'city' => 'Aarhus',
|
|
'zip' => '8000',
|
|
],
|
|
];
|
|
|
|
$result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[
|
|
'id' => 88,
|
|
'object_type' => 'orders',
|
|
'object_id' => 501,
|
|
'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE),
|
|
'customer_number' => 777,
|
|
'department_id' => 12,
|
|
'order_reference' => 'REF-501',
|
|
'customer_name' => 'Acme Transport',
|
|
'updated_at' => '2026-03-11 12:00:00',
|
|
'created_at' => '2026-03-10 12:00:00',
|
|
], ['acme', '777'], 9]);
|
|
|
|
expect($result['entity_type'])->toBe('objects');
|
|
expect($result['customer_number'])->toBe(777);
|
|
expect($result['customer_name'])->toBe('Acme Transport');
|
|
expect($result['customer_barred'])->toBeTrue();
|
|
expect($result['customer_status'])->toBe('barred');
|
|
expect($result['description'])->toBe('REF-501 / Acme Transport');
|
|
expect($result['payload']['linked_entity_type'])->toBe('orders');
|
|
expect($result['payload']['order_reference'])->toBe('REF-501');
|
|
expect($result['payload']['customer_context']['cvr'])->toBe('12345678');
|
|
});
|
|
|
|
it('includes the economic customer index in cache dependencies for customer scoped results', function (): void {
|
|
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
|
|
|
|
$tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]);
|
|
|
|
expect($tables)->toContain(system_search_economic_customer_index::TABLE);
|
|
});
|