Add comprehensive unit tests for invoicing period cache logic, system search behavior, and OpenAPI spec coverage. Expand search service with generic entity support and refine association type handling.

This commit is contained in:
Jeppe Bundgaard
2026-03-12 21:21:14 +01:00
parent 1190290899
commit a22a3f4ee9
8 changed files with 804 additions and 26 deletions
@@ -165,7 +165,7 @@ class system_search_service
if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect(
$activeTypes,
['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices']
$this->associationEntityTypes()
));
foreach ($customerNumbers as $customerNumber) {
$associated = $this->executeLexicalSearch(
@@ -290,6 +290,17 @@ class system_search_service
array $moduleConfigVisibility,
array $forcedCustomerNumbers
): array {
if ($this->isGenericEntityType($entityType)) {
return $this->searchGenericEntity(
$entityType,
$terms,
$entityBoost,
$ownOnly,
$ownCustomerNumber,
$forcedCustomerNumbers
);
}
return match ($entityType) {
'customers' => $this->searchCustomers($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
'employees' => $this->searchEmployees($terms, $entityBoost, $ownOnly, $ownCustomerNumber),
@@ -751,6 +762,303 @@ class system_search_service
}, $rows);
}
private function isGenericEntityType(string $entityType): bool
{
$configs = $this->genericEntityConfigs();
return isset($configs[$entityType]);
}
/**
* @return array<int, string>
*/
private function associationEntityTypes(): array
{
$types = ['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices'];
foreach ($this->genericEntityConfigs() as $entityType => $config) {
if (isset($config['customer_field']) && is_string($config['customer_field']) && $config['customer_field'] !== '') {
$types[] = $entityType;
}
}
return array_values(array_unique($types));
}
/**
* @param array<int, string> $terms
* @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>>
*/
private function searchGenericEntity(
string $entityType,
array $terms,
int $entityBoost,
bool $ownOnly,
?int $ownCustomerNumber,
array $forcedCustomerNumbers = []
): array {
if (empty($terms)) {
return [];
}
$config = $this->genericEntityConfigs()[$entityType] ?? null;
if (!is_array($config)) {
return [];
}
$table = trim((string)($config['table'] ?? ''));
if ($table === '' || !$this->tableExists($table)) {
return [];
}
$columns = $this->getColumns($table);
if (empty($columns)) {
return [];
}
$idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0]));
if (!in_array($idField, $columns, true)) {
return [];
}
$customerField = null;
if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) {
$customerField = $config['customer_field'];
}
if ($ownOnly) {
if ($customerField === null) {
return [];
}
if (empty($forcedCustomerNumbers) && $ownCustomerNumber === null) {
return [];
}
}
$departmentField = null;
if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) {
$departmentField = $config['department_field'];
}
$excludedColumns = [];
if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) {
$excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($v) => is_string($v) && $v !== ''));
}
$searchable = [];
if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) {
$configured = array_values(array_filter($config['search_fields'], static fn($v) => is_string($v) && $v !== ''));
$configured = array_values(array_intersect($configured, $columns));
$searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns);
}
if (empty($searchable)) {
$searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns);
}
if (empty($searchable)) {
return [];
}
$selectFields = array_values(array_unique(array_filter([
$idField,
$customerField,
$departmentField,
...$searchable,
], static fn($value) => is_string($value) && $value !== '')));
if (count($selectFields) > 24) {
$selectFields = array_slice($selectFields, 0, 24);
}
$searchable = array_values(array_intersect($searchable, $selectFields));
$fixedConditions = [];
if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) {
foreach ($config['fixed_conditions'] as $column => $value) {
if (!is_string($column) || !in_array($column, $columns, true)) {
continue;
}
$fixedConditions[$column] = $value;
}
}
if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) {
$fixedConditions['deleted_at'] = null;
}
$customerNumbers = !empty($forcedCustomerNumbers)
? $forcedCustomerNumbers
: (($ownOnly && $ownCustomerNumber !== null && $customerField !== null) ? [$ownCustomerNumber] : []);
$rows = $this->searchTable(
$table,
$selectFields,
$searchable,
$terms,
$customerNumbers,
$customerField,
$fixedConditions
);
$titleFields = [];
if (isset($config['title_fields']) && is_array($config['title_fields'])) {
$titleFields = array_values(array_filter($config['title_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true)));
}
if (empty($titleFields)) {
$titleFields = array_values(array_intersect(
['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'],
$selectFields
));
}
$descriptionFields = [];
if (isset($config['description_fields']) && is_array($config['description_fields'])) {
$descriptionFields = array_values(array_filter($config['description_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true)));
}
if (empty($descriptionFields)) {
$descriptionFields = array_values(array_intersect(
['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'],
$selectFields
));
}
$entityLabel = ucfirst(str_replace('_', ' ', $entityType));
$results = [];
foreach ($rows as $row) {
$entityId = isset($row[$idField]) ? (string)$row[$idField] : md5(json_encode($row, JSON_UNESCAPED_UNICODE));
$title = '';
foreach ($titleFields as $field) {
$value = trim((string)($row[$field] ?? ''));
if ($value !== '') {
$title = $value;
break;
}
}
if ($title === '') {
$title = $entityLabel . ' #' . $entityId;
}
$descriptionParts = [];
foreach ($descriptionFields as $field) {
$value = trim((string)($row[$field] ?? ''));
if ($value === '') {
continue;
}
$descriptionParts[] = $value;
if (count($descriptionParts) >= 2) {
break;
}
}
$description = implode(' / ', $descriptionParts);
$results[] = [
'entity_type' => $entityType,
'entity_id' => $entityId,
'title' => $title,
'description' => $description,
'customer_number' => ($customerField !== null && isset($row[$customerField])) ? $this->toIntOrNull($row[$customerField]) : null,
'department_id' => ($departmentField !== null && isset($row[$departmentField])) ? $this->toIntOrNull($row[$departmentField]) : null,
'score' => $this->scoreRow($row, $searchable, $terms) + $entityBoost,
'payload' => array_intersect_key($row, array_flip($selectFields)),
];
}
return $results;
}
/**
* @param array<int, string> $columns
* @param array<int, string> $excludeColumns
* @return array<int, string>
*/
private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array
{
$excluded = array_values(array_unique(array_map(static fn($v) => mb_strtolower((string)$v), $excludeColumns)));
$filtered = [];
foreach ($columns as $column) {
if (!is_string($column) || $column === '') {
continue;
}
$lower = mb_strtolower($column);
if (in_array($lower, $excluded, true)) {
continue;
}
if (in_array($lower, ['created_at', 'updated_at'], true)) {
continue;
}
if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) {
continue;
}
if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) {
continue;
}
$filtered[] = $column;
}
return array_values(array_unique($filtered));
}
/**
* @return array<string, array<string, mixed>>
*/
private function genericEntityConfigs(): array
{
return [
'bookings' => ['table' => 'bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'bookings_new' => ['table' => 'bookings_new', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'branding' => ['table' => 'branding'],
'categories' => ['table' => 'categories'],
'currency_conversion_rates' => ['table' => 'currency_conversion_rates'],
'customer_codes' => ['table' => 'customer_codes'],
'customer_default_department' => ['table' => 'customer_default_department', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'customer_notes' => ['table' => 'customer_notes', 'customer_field' => 'customer_id'],
'customer_vehicles_addons' => ['table' => 'customer_vehicles_addons'],
'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'],
'department_daily_reports' => ['table' => 'department_daily_reports', 'department_field' => 'department_id'],
'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'],
'department_goals' => ['table' => 'goals'],
'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'],
'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'],
'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'],
'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'],
'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'],
'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'],
'department_selfserve_tasks' => ['table' => 'department_selfserve_tasks', 'department_field' => 'department'],
'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'],
'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'],
'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'],
'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'],
'department_variables' => ['table' => 'department_variables'],
'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'],
'module_action_logs' => ['table' => 'module_usage_logs'],
'motorapi_lookups' => ['table' => 'motorapi_lookups'],
'notifications' => ['table' => 'notifications'],
'order_bookings' => ['table' => 'order_bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'],
'plate_scans' => ['table' => 'plate_scans'],
'product_options' => ['table' => 'products_options'],
'products' => ['table' => 'products'],
'stripe_module_customers' => ['table' => 'stripe_module_customers', 'customer_field' => 'customer_id'],
'stripe_module_orders' => ['table' => 'stripe_module_orders', 'customer_field' => 'customer_id', 'exclude_columns' => ['url']],
'stripe_payment_intents' => ['table' => 'stripe_payment_intents', 'exclude_columns' => ['client_secret', 'data']],
'subuser_grants' => ['table' => 'subuser_grants', 'customer_field' => 'billing_customer_number'],
'xlvask_customers' => ['table' => 'xlvask_customers'],
'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'],
'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'],
'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'],
'xlvask_vehicles' => ['table' => 'xlvask_vehicles'],
];
}
private function toIntOrNull(mixed $value): ?int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?\d+$/', $value)) {
return (int)$value;
}
if (is_float($value)) {
return (int)$value;
}
return null;
}
/**
* Generic table search helper.
*
@@ -1063,7 +1371,7 @@ class system_search_service
private function allEntityTypes(): array
{
return [
return array_values(array_unique([
'objects',
'module_config',
'orders',
@@ -1078,7 +1386,8 @@ class system_search_service
'roles',
'invoices',
'vehicles',
];
...array_keys($this->genericEntityConfigs()),
]));
}
private function taxonomy(array $activeTypes): array
@@ -1098,10 +1407,31 @@ class system_search_service
'roles' => ['role', 'group'],
'module_config' => ['module config', 'setting', 'configuration'],
'objects' => ['attachment', 'object'],
'bookings' => ['booking', 'wash booking'],
'bookings_new' => ['new booking', 'booking queue'],
'customer_notes' => ['customer note', 'note'],
'order_bookings' => ['order booking', 'scheduled order'],
'products' => ['product', 'service'],
'product_options' => ['product option', 'addon', 'add on'],
'plate_scans' => ['plate scan', 'license plate scan'],
'plate_scanners' => ['plate scanner', 'license plate scanner'],
'notifications' => ['notification', 'alert'],
'module_action_logs' => ['module log', 'action log'],
'motorapi_lookups' => ['motorapi lookup', 'plate lookup'],
'xlvask_customers' => ['xlvask customer'],
'xlvask_vehicles' => ['xlvask vehicle'],
'xlvask_usage_logs' => ['xlvask usage log'],
'department_daily_reports' => ['department daily report', 'daily report'],
];
$taxonomy = [];
foreach ($activeTypes as $type) {
$taxonomy[$type] = $aliases[$type] ?? [];
$resolved = $aliases[$type] ?? [];
if (empty($resolved)) {
$human = str_replace('_', ' ', $type);
$singular = rtrim($human, 's');
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
}
$taxonomy[$type] = $resolved;
}
return $taxonomy;
}
+1 -1
View File
@@ -1208,7 +1208,7 @@ class users_o extends db
}
$customerNumbers = [];
$sql = "SELECT customer_number FROM `$this->table` WHERE id IN (" . implode(',', array_map('intval', $userIds)) . ")";
$sql = "SELECT customer_number FROM $this->table WHERE id IN (" . implode(',', array_map('intval', $userIds)) . ")";
$result = $db->query($sql);
while ($row = $result->fetch_assoc()) {
$customerNumbers[] = (int)$row['customer_number'];
+177 -16
View File
@@ -211,6 +211,182 @@ class systemSearchRoute
'all' => ['list_vehicles_other', 'list_unknown_customer_vehicles'],
'own' => ['list_own_vehicles'],
],
'bookings' => [
'all' => ['list_bookings'],
'own' => ['list_own_bookings'],
],
'bookings_new' => [
'all' => ['list_bookings', 'statistics_bookings_new'],
'own' => ['list_own_bookings'],
],
'branding' => [
'all' => ['list_branding_options'],
'own' => [],
],
'categories' => [
'all' => ['list_categories'],
'own' => [],
],
'currency_conversion_rates' => [
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
'own' => [],
],
'customer_codes' => [
'all' => ['get_customer_code', 'add_customer_code'],
'own' => [],
],
'customer_default_department' => [
'all' => ['get_customer_default_department_other', 'add_customer_default_department_other', 'delete_customer_default_department_other'],
'own' => ['get_customer_default_department', 'add_customer_default_department', 'delete_customer_default_department'],
],
'customer_notes' => [
'all' => ['list_customer_notes'],
'own' => [],
],
'customer_vehicles_addons' => [
'all' => ['list_vehicles_addon_other', 'list_vehicle_customer_suggestions'],
'own' => ['list_vehicle_addon_own'],
],
'department_categories' => [
'all' => ['list_department_categories'],
'own' => [],
],
'department_daily_reports' => [
'all' => ['list_department_daily_reports'],
'own' => [],
],
'department_gates' => [
'all' => ['list_department_gates'],
'own' => [],
],
'department_goals' => [
'all' => ['goals_department_list'],
'own' => [],
],
'department_lanes' => [
'all' => ['list_department_lanes'],
'own' => [],
],
'department_notification_sms' => [
'all' => ['department_notification_sms_get'],
'own' => [],
],
'department_relays' => [
'all' => ['list_department_relays'],
'own' => [],
],
'department_selfserve_condition_rules' => [
'all' => ['list_department_selfserve_condition_rules'],
'own' => [],
],
'department_selfserve_conditions' => [
'all' => ['list_department_selfserve_conditions'],
'own' => [],
],
'department_selfserve_questions' => [
'all' => ['list_department_selfserve_questions'],
'own' => [],
],
'department_selfserve_tasks' => [
'all' => ['list_department_selfserve_tasks'],
'own' => [],
],
'department_selfserve_vehicle_conditions' => [
'all' => ['list_department_selfserve_vehicle_conditions'],
'own' => ['list_own_department_selfserve_vehicle_conditions'],
],
'department_time_bookings_entries' => [
'all' => ['department_timebookings_entries_get'],
'own' => [],
],
'department_time_bookings_opening_hours' => [
'all' => ['department_timebookings_opening_hours_get'],
'own' => [],
],
'department_time_bookings_types' => [
'all' => ['department_timebookings_types_get'],
'own' => [],
],
'department_variables' => [
'all' => ['superuser_fetch_department_variables', 'superuser_set_department_variables'],
'own' => [],
],
'fxratesapi_conversion_rates' => [
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
'own' => [],
],
'module_action_logs' => [
'all' => ['modules_action_logs_view'],
'own' => [],
],
'motorapi_lookups' => [
'all' => ['modules_motorapi_lookup', 'department_license_plate_lookup'],
'own' => [],
],
'notifications' => [
'all' => ['list_all_notifications', 'list_notifications'],
'own' => ['list_own_notifications'],
],
'order_bookings' => [
'all' => ['list_bookings'],
'own' => ['list_own_bookings'],
],
'plate_scanners' => [
'all' => ['list_number_plate_scanners', 'list_department_number_plate_scanners'],
'own' => [],
],
'plate_scans' => [
'all' => ['list_number_plate_scans', 'list_number_plate_scans_department'],
'own' => [],
],
'product_options' => [
'all' => ['list_product_options'],
'own' => [],
],
'products' => [
'all' => ['list_products', 'economic_products_get'],
'own' => [],
],
'stripe_module_customers' => [
'all' => ['modules_stripe_customers_list'],
'own' => [],
],
'stripe_module_orders' => [
'all' => ['list_orders', 'fetch_order'],
'own' => ['list_own_orders', 'fetch_own_order'],
],
'stripe_payment_intents' => [
'all' => ['get_payment_intent', 'confirm_payment_intent'],
'own' => [],
],
'subuser_grants' => [
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
'own' => ['list_own_subuser_grants'],
],
'xlvask_customers' => [
'all' => ['modules_xlvask_customers'],
'own' => [],
],
'xlvask_potential_order_matches' => [
'all' => ['list_potential_order_matches'],
'own' => ['list_own_potential_order_matches'],
],
'xlvask_usage_log_wash_items' => [
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
'own' => ['list_xlvask_usage_orders_own'],
],
'xlvask_usage_logs' => [
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
'own' => ['list_xlvask_usage_orders_own'],
],
'xlvask_vehicle_types' => [
'all' => ['modules_xlvask_internal_vehicle_types'],
'own' => [],
],
'xlvask_vehicles' => [
'all' => ['modules_xlvask_vehicles'],
'own' => [],
],
];
}
@@ -396,21 +572,6 @@ class systemSearchRoute
private function allEntityTypes(): array
{
return [
'objects',
'module_config',
'orders',
'order_items',
'customers',
'employees',
'subusers',
'customer_discounts',
'customer_fixed_prices',
'departments',
'permissions',
'roles',
'invoices',
'vehicles',
];
return array_keys($this->entityPermissionMap());
}
}
@@ -0,0 +1,84 @@
<?php
app_require('routes/InvoicingPeriodRoute.php');
use routes\InvoicingPeriodRoute;
function invoicing_period_route_invoke_private(InvoicingPeriodRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
beforeEach(function (): void {
$this->oldTtl = getenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL');
});
afterEach(function (): void {
if ($this->oldTtl === false) {
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL');
return;
}
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
$_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all';
$route = new InvoicingPeriodRoute();
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL');
expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(300);
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=120');
expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(120);
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=-10');
expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(0);
});
it('builds deterministic cache keys per scope and date range', function (): void {
$_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all';
$route = new InvoicingPeriodRoute();
$keyA = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [
'all',
'2026-01-01 00:00:00',
'2026-01-31 23:59:59',
]);
$keyB = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [
'all',
'2026-01-01 00:00:00',
'2026-01-31 23:59:59',
]);
$keyC = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [
'wash-subscriptions',
'2026-01-01 00:00:00',
'2026-01-31 23:59:59',
]);
expect($keyA)->toBe($keyB);
expect($keyA)->not->toBe($keyC);
expect($keyA)->toStartWith('invoicing_period:distribution:v2:all:');
});
it('falls back to resolver directly when cache ttl is disabled', function (): void {
$_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all';
$route = new InvoicingPeriodRoute();
putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=0');
$calls = 0;
$result = invoicing_period_route_invoke_private($route, 'withCachedDistributionV2', [
'all',
'2026-01-01 00:00:00',
'2026-01-31 23:59:59',
function () use (&$calls): array {
$calls++;
return ['ok' => true];
},
]);
expect($result)->toBe(['ok' => true]);
expect($calls)->toBe(1);
});
@@ -0,0 +1,90 @@
<?php
app_require('routes/systemSearchRoute.php');
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_service.php');
use classes\system_search_service;
use interfaces\system_search_intent_parser_i;
use routes\systemSearchRoute;
if (!class_exists('SystemSearchNullIntentParserForCoverage')) {
class SystemSearchNullIntentParserForCoverage implements system_search_intent_parser_i
{
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'disabled',
'source' => 'none',
];
}
}
}
function system_search_entity_coverage_invoke_private(object $instance, string $method): array
{
$reflection = new ReflectionClass($instance);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return (array)$target->invoke($instance);
}
function system_search_openapi_content_or_skip_for_coverage(): string
{
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
it('keeps route and service entity type registries in sync with expanded coverage', function (): void {
$_SERVER['REQUEST_URI'] = '/search/system';
$route = new systemSearchRoute();
$routeTypes = system_search_entity_coverage_invoke_private($route, 'allEntityTypes');
$service = new system_search_service(new SystemSearchNullIntentParserForCoverage());
$serviceTypes = system_search_entity_coverage_invoke_private($service, 'allEntityTypes');
expect($routeTypes)->toBe($serviceTypes);
expect($routeTypes)->toContain('bookings');
expect($routeTypes)->toContain('products');
expect($routeTypes)->toContain('plate_scans');
expect($routeTypes)->toContain('department_time_bookings_entries');
expect($routeTypes)->toContain('xlvask_usage_logs');
expect($routeTypes)->toContain('stripe_module_orders');
});
it('documents every supported search entity type in openapi enum', function (): void {
$_SERVER['REQUEST_URI'] = '/search/system';
$content = system_search_openapi_content_or_skip_for_coverage();
$service = new system_search_service(new SystemSearchNullIntentParserForCoverage());
$types = system_search_entity_coverage_invoke_private($service, 'allEntityTypes');
foreach ($types as $type) {
expect($content)->toContain('- ' . $type);
}
});
@@ -2,11 +2,16 @@
function system_search_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
@@ -0,0 +1,64 @@
<?php
app_require('routes/systemSearchRoute.php');
use routes\systemSearchRoute;
function system_search_route_invoke_private(systemSearchRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
it('normalizes type lists from csv json and arrays', function (): void {
$_SERVER['REQUEST_URI'] = '/search/system';
$route = new systemSearchRoute();
$csv = system_search_route_invoke_private($route, 'parseTypeList', [' Orders,customers ,ORDERS']);
$json = system_search_route_invoke_private($route, 'parseTypeList', ['["invoices","Orders","invoices"]']);
$array = system_search_route_invoke_private($route, 'parseTypeList', [[
' Vehicles ',
'vehicles',
'ORDERS',
123,
]]);
expect($csv)->toBe(['orders', 'customers']);
expect($json)->toBe(['invoices', 'orders']);
expect($array)->toBe(['vehicles', 'orders']);
});
it('parses booleans and clamps integers using route defaults', function (): void {
$_SERVER['REQUEST_URI'] = '/search/system';
$route = new systemSearchRoute();
expect(system_search_route_invoke_private($route, 'toBool', ['true', false]))->toBeTrue();
expect(system_search_route_invoke_private($route, 'toBool', ['0', true]))->toBeFalse();
expect(system_search_route_invoke_private($route, 'toBool', ['not-a-bool', true]))->toBeTrue();
expect(system_search_route_invoke_private($route, 'clampInt', [0, 1, 200, 50]))->toBe(50);
expect(system_search_route_invoke_private($route, 'clampInt', [999, 1, 200, 50]))->toBe(200);
expect(system_search_route_invoke_private($route, 'clampInt', [-4, 1, 200, 50]))->toBe(1);
});
it('exposes expected searchable entity types', function (): void {
$_SERVER['REQUEST_URI'] = '/search/system';
$route = new systemSearchRoute();
$types = system_search_route_invoke_private($route, 'allEntityTypes');
expect($types)->toContain('orders');
expect($types)->toContain('customers');
expect($types)->toContain('invoices');
expect($types)->toContain('module_config');
expect($types)->toContain('objects');
expect($types)->toContain('bookings');
expect($types)->toContain('products');
expect($types)->toContain('product_options');
expect($types)->toContain('plate_scans');
expect($types)->toContain('notifications');
expect($types)->toContain('department_time_bookings_entries');
expect($types)->toContain('stripe_module_orders');
expect($types)->toContain('xlvask_usage_logs');
});