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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user