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('TestableSystemSearchService')) { class TestableSystemSearchService extends system_search_service { /** * @var array> */ public array $lexicalCalls = []; /** * @var array>> */ private array $queuedLexicalResults; public function __construct(array $queuedLexicalResults) { $this->queuedLexicalResults = $queuedLexicalResults; parent::__construct(); } protected function executeLexicalSearch( array $activeTypes, array $terms, array $entityBoost, array $ownOnlyTypes, ?int $ownCustomerNumber, array $permissionsCatalogAll, array $permissionsCatalogOwn, array $moduleConfigVisibility, array $allowedDepartmentIds = [], array $forcedCustomerNumbers = [] ): array { $this->lexicalCalls[] = [ 'activeTypes' => $activeTypes, 'terms' => $terms, 'entityBoost' => $entityBoost, 'ownOnlyTypes' => $ownOnlyTypes, 'ownCustomerNumber' => $ownCustomerNumber, 'allowedDepartmentIds' => $allowedDepartmentIds, 'forcedCustomerNumbers' => $forcedCustomerNumbers, ]; if (empty($this->queuedLexicalResults)) { return []; } return array_shift($this->queuedLexicalResults); } } } if (!class_exists('CustomerContextAwareTestableSystemSearchService')) { class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService { /** * @var array> */ 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('returns a single capped result set without pagination or intent metadata', function (): void { $rows = []; for ($i = 1; $i <= 55; $i++) { $rows[] = ['entity_type' => 'orders', 'entity_id' => (string)$i, 'title' => 'Order #' . $i, 'score' => 100 - ($i % 10)]; } $service = new TestableSystemSearchService([$rows]); $result = $service->search([ 'query' => 'order', 'allowed_types' => ['orders'], 'max_results' => 500, 'include_associations' => false, ]); expect(count($result['results']))->toBe(50); expect($result['meta']['max_results'])->toBe(50); expect($result['meta']['returned'])->toBe(50); expect($result['meta']['truncated'])->toBeTrue(); expect($result['meta'])->not->toHaveKey('limit'); expect($result['meta'])->not->toHaveKey('offset'); expect($result['meta'])->not->toHaveKey('intent_parser'); }); it('honors smaller max_results values for one-shot search responses', function (): void { $service = new TestableSystemSearchService([[ ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 100], ['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 99], ['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 98], ['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 97], ]]); $result = $service->search([ 'query' => 'order', 'allowed_types' => ['orders'], 'max_results' => 3, 'include_associations' => false, ]); expect(array_column($result['results'], 'entity_id'))->toBe(['1', '2', '3']); expect($result['meta']['returned'])->toBe(3); expect($result['meta']['truncated'])->toBeTrue(); }); it('uses default search types unless the query names a lower priority entity type', function (): void { $service = new TestableSystemSearchService([[]]); $service->search([ 'query' => 'acme', 'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'], 'include_associations' => false, ]); expect($service->lexicalCalls[0]['activeTypes'])->toContain('customers'); expect($service->lexicalCalls[0]['activeTypes'])->toContain('orders'); expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('module_config'); expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('permissions'); $service = new TestableSystemSearchService([[]]); $service->search([ 'query' => 'stripe module config', 'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'], 'include_associations' => false, ]); expect($service->lexicalCalls[0]['activeTypes'])->toContain('module_config'); }); it('lets explicit include type filters search and return focused lower-score matches', function (): void { $service = new TestableSystemSearchService([[ ['entity_type' => 'module_config', 'entity_id' => 'Stripe:enabled', 'title' => 'Stripe.enabled', 'score' => 30], ['entity_type' => 'module_config', 'entity_id' => 'Stripe:key', 'title' => 'Stripe.api_key', 'score' => 10], ]]); $result = $service->search([ 'query' => 'stripe', 'include_types' => ['module_config'], 'allowed_types' => ['module_config'], 'include_associations' => false, ]); expect(array_column($result['results'], 'entity_id'))->toBe(['Stripe:enabled']); }); it('drops weak non-explicit matches after relevance penalties', function (): void { $service = new TestableSystemSearchService([[ ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 30], ['entity_type' => 'module_config', 'entity_id' => 'openAI:enabled', 'title' => 'openAI.enabled', 'score' => 100], ['entity_type' => 'vehicles', 'entity_id' => '3', 'title' => 'Vehicle #3', 'score' => 5], ]]); $result = $service->search([ 'query' => 'acme', 'allowed_types' => ['orders', 'module_config', 'vehicles'], 'include_associations' => false, ]); expect(array_column($result['results'], 'entity_id'))->toBe(['1']); }); it('scopes query cache by permission context and result cap', function (): void { system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter()); $service = new TestableSystemSearchService([ [['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, 'max_results' => 1, 'permissions_catalog_own' => ['list_orders'], ]); $cached = $service->search([ 'query' => 'acme', 'allowed_types' => ['orders'], 'include_associations' => false, 'max_results' => 1, 'permissions_catalog_own' => ['list_orders'], ]); $secondCap = $service->search([ 'query' => 'acme', 'allowed_types' => ['orders'], 'include_associations' => false, 'max_results' => 2, 'permissions_catalog_own' => ['list_orders'], ]); expect(count($service->lexicalCalls))->toBe(2); expect($first['results'][0]['entity_id'])->toBe('1'); expect($cached['results'][0]['entity_id'])->toBe('1'); expect($cached['meta']['cache']['hit'])->toBeTrue(); expect($secondCap['results'][0]['entity_id'])->toBe('2'); }); it('only expands associations from strong customer matches', function (): void { $strong = new TestableSystemSearchService([ [[ 'entity_type' => 'customers', 'entity_id' => '10', 'title' => 'Acme', 'customer_number' => 777, 'score' => 90, ]], [[ 'entity_type' => 'orders', 'entity_id' => '501', 'title' => 'Order #501', 'customer_number' => 777, 'score' => 95, ]], ]); $strongResult = $strong->search([ 'query' => 'acme', 'allowed_types' => ['customers', 'orders'], 'include_associations' => true, ]); expect(count($strong->lexicalCalls))->toBe(2); expect($strong->lexicalCalls[1]['forcedCustomerNumbers'])->toBe([777]); expect(array_column($strongResult['results'], 'entity_type'))->toContain('orders'); $weak = new TestableSystemSearchService([ [[ 'entity_type' => 'customers', 'entity_id' => '10', 'title' => 'Acme', 'customer_number' => 777, 'score' => 20, ]], [[ 'entity_type' => 'orders', 'entity_id' => '501', 'title' => 'Order #501', 'customer_number' => 777, 'score' => 95, ]], ]); $weak->search([ 'query' => 'acme', 'allowed_types' => ['customers', 'orders'], 'include_associations' => true, ]); expect(count($weak->lexicalCalls))->toBe(1); }); it('passes allowed department ids into lexical execution context', function (): void { $service = new TestableSystemSearchService([[ ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 70], ]]); $service->search([ 'query' => 'order', 'allowed_types' => ['orders'], 'include_associations' => false, 'allowed_department_ids' => [3, '7', 3], ]); expect(count($service->lexicalCalls))->toBe(1); expect($service->lexicalCalls[0]['allowedDepartmentIds'])->toBe([3, 7]); }); it('only applies indexed department filters to department-scoped indexed entities', function (): void { $service = new TestableSystemSearchService([]); expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['orders']))->toBeTrue(); expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['objects']))->toBeTrue(); expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['bookings']))->toBeTrue(); expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['customers']))->toBeFalse(); expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['invoices']))->toBeFalse(); }); it('expands danish discount wording into lexical discount synonyms', function (): void { $service = new TestableSystemSearchService([[ ['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95], ]]); $service->search([ 'query' => 'pleno rabat', 'allowed_types' => ['customer_discounts'], 'include_types' => ['customer_discounts'], 'include_associations' => false, ]); $terms = $service->lexicalCalls[0]['terms'] ?? []; expect($terms)->toContain('rabat'); expect($terms)->toContain('discount'); }); it('prefers newer records when relevance scores are comparable', function (): void { $service = new TestableSystemSearchService([[ [ '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'], ], ]]); $result = $service->search([ 'query' => 'booking', 'allowed_types' => ['bookings'], 'include_associations' => false, ]); expect($result['results'][0]['entity_id'])->toBe('2'); }); it('keeps explicit identifier matches ahead of newer but weaker records', function (): void { $service = new TestableSystemSearchService([[ [ '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'], ], ]]); $result = $service->search([ 'query' => '12345', 'allowed_types' => ['orders'], 'include_associations' => false, ]); expect($result['results'][0]['entity_id'])->toBe('100'); }); it('promotes invoices orders order bookings and customers in ranking', function (): void { $service = new TestableSystemSearchService([[ ['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(array_slice($types, 0, 4))->toBe([ 'invoices', 'orders', 'order_bookings', 'customers', ]); }); it('never prioritizes cancelled bookings over active bookings', function (): void { $service = new TestableSystemSearchService([[ [ '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', ], ], ]]); $result = $service->search([ 'query' => 'booking', 'allowed_types' => ['bookings'], 'include_associations' => false, ]); expect($result['results'][0]['entity_id'])->toBe('501'); }); it('tokenizes unicode names without stripping non ascii letters', function (): void { $service = new TestableSystemSearchService([]); $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('requires broader term coverage for multi word scoring', function (): void { $service = new TestableSystemSearchService([]); $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([]); $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([]); $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([]); $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([]); $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([]); $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([]); $tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]); expect($tables)->toContain(system_search_economic_customer_index::TABLE); });