Add unit tests for superuser-vehicle-analytics, superuser-vehicle-view, and advanced system search components:

- Cover query builders, data normalization, and analytics aggregations for vehicle view utilities.
- Validate route contracts, tab structure, and OpenAPI integration for `superuser-vehicle-view`.
- Test advanced search modal logic, route registration, registry compliance, and UI behavior.
- Ensure system search registry and navigation resolvers provide complete type coverage and fallback handling.
This commit is contained in:
Jeppe Bundgaard
2026-03-12 23:10:20 +01:00
parent bade01b92a
commit 253dc1d357
8 changed files with 2194 additions and 576 deletions
@@ -0,0 +1,850 @@
import type { RouteLocationRaw } from 'vue-router';
export const SYSTEM_SEARCH_ENTITY_TYPES = [
'objects', 'module_config', 'orders', 'order_items', 'customers', 'employees', 'subusers',
'customer_discounts', 'customer_fixed_prices', 'departments', 'permissions', 'roles', 'invoices', 'vehicles',
'bookings', 'bookings_new', 'branding', 'categories', 'currency_conversion_rates', 'customer_codes',
'customer_default_department', 'customer_notes', 'customer_vehicles_addons', 'department_categories',
'department_daily_reports', 'department_gates', 'department_goals', 'department_lanes',
'department_notification_sms', 'department_relays', 'department_selfserve_condition_rules',
'department_selfserve_conditions', 'department_selfserve_questions', 'department_selfserve_tasks',
'department_selfserve_vehicle_conditions', 'department_time_bookings_entries',
'department_time_bookings_opening_hours', 'department_time_bookings_types', 'department_variables',
'fxratesapi_conversion_rates', 'module_action_logs', 'motorapi_lookups', 'notifications', 'order_bookings',
'plate_scanners', 'plate_scans', 'product_options', 'products', 'stripe_module_customers',
'stripe_module_orders', 'stripe_payment_intents', 'subuser_grants', 'xlvask_customers',
'xlvask_potential_order_matches', 'xlvask_usage_log_wash_items', 'xlvask_usage_logs',
'xlvask_vehicle_types', 'xlvask_vehicles'
] as const;
export type EntityType = typeof SYSTEM_SEARCH_ENTITY_TYPES[number];
export type SearchResult = {
entity_type: EntityType;
entity_id: string;
title: string;
description?: string | null;
customer_number?: number | null;
department_id?: number | null;
score: number;
association_reason?: string | null;
payload?: unknown;
};
export type SearchPayload = Record<string, unknown>;
export type SystemSearchNavigationStrategy = 'deep-link' | 'module' | 'generic';
export type SystemSearchNavigationTarget = {
strategy: SystemSearchNavigationStrategy;
to: RouteLocationRaw;
};
export type SystemSearchBadge = {
label: string;
type: 'is-info' | 'is-success' | 'is-warning' | 'is-light';
};
export type SystemSearchKeyField = {
label: string;
value: string;
};
export type SystemSearchViewModel = {
title: string;
description: string;
badges: SystemSearchBadge[];
keyFields: SystemSearchKeyField[];
rawPayload: SearchPayload | null;
parsedPayload: SearchPayload | null;
};
export type SystemSearchAccessContext = {
canAccessSuperUser: boolean;
canAccessAdmin: boolean;
canAccessUser: boolean;
canAccessDepartment: (departmentId: number) => boolean;
};
export type SystemSearchNavigationResolveContext = {
searchQuery?: string;
allowGenericFallback?: boolean;
};
type FieldSelector = {
label: string;
keys: string[];
};
type NavigationResolver = (
result: SearchResult,
payload: SearchPayload | null,
access: SystemSearchAccessContext,
context?: SystemSearchNavigationResolveContext
) => SystemSearchNavigationTarget | null;
export type SystemSearchSupportDefinition = {
entityType: EntityType;
navigationStrategy: SystemSearchNavigationStrategy;
titleKeys: string[];
descriptionKeys: string[];
keyFieldSelectors: FieldSelector[];
resolveNavigation: NavigationResolver;
};
const deepLinkTypes = new Set<EntityType>([
'orders',
'order_items',
'vehicles',
'invoices',
'products',
'departments',
'roles'
]);
const moduleTypes = new Set<EntityType>([
'bookings',
'bookings_new',
'order_bookings',
'department_selfserve_condition_rules',
'department_selfserve_conditions',
'department_selfserve_questions',
'department_selfserve_tasks',
'department_selfserve_vehicle_conditions',
'department_time_bookings_entries',
'department_time_bookings_opening_hours',
'department_time_bookings_types',
'department_daily_reports',
'department_goals',
'department_notification_sms',
'department_lanes',
'department_gates',
'department_relays',
'categories',
'customers',
'subusers',
'subuser_grants',
'plate_scanners',
'plate_scans',
'xlvask_customers',
'xlvask_usage_logs',
'xlvask_vehicles',
'xlvask_vehicle_types',
'motorapi_lookups',
'fxratesapi_conversion_rates',
'currency_conversion_rates',
'stripe_module_customers',
'stripe_module_orders',
'stripe_payment_intents',
'module_config'
]);
const field = (label: string, ...keys: string[]): FieldSelector => ({ label, keys });
const fieldList = (...entries: FieldSelector[]): FieldSelector[] => entries;
const defaultTitleKeys = ['name', 'reference', 'title', 'external_id', 'id'];
const defaultDescriptionKeys = ['description', 'notes', 'note', 'status', 'email', 'address'];
const defaultKeyFields = fieldList(
field('Name', 'name'),
field('Reference', 'reference'),
field('Status', 'status'),
field('Updated', 'updated', 'updated_at'),
field('Created', 'created_at')
);
const titleKeyOverrides: Partial<Record<EntityType, string[]>> = {
orders: ['reference', 'reg_1', 'id'],
order_items: ['reference', 'order_id', 'id'],
bookings: ['reference_number', 'regNrTraekker', 'id'],
bookings_new: ['reference_number', 'regNrTraekker', 'id'],
order_bookings: ['reference', 'po', 'reg_1', 'id'],
products: ['name', 'id'],
xlvask_usage_logs: ['RegistrationNumber', 'WashId', 'id'],
xlvask_customers: ['name', 'customerId', 'id'],
xlvask_vehicles: ['registrationNumber', 'vehicleId', 'id'],
customer_discounts: ['customer_number', 'product_or_category_id', 'id'],
invoices: ['external_id', 'name', 'id'],
motorapi_lookups: ['license_plate', 'id'],
fxratesapi_conversion_rates: ['base', 'target', 'id'],
module_config: ['key', 'name', 'id'],
stripe_module_customers: ['customer_id', 'id'],
stripe_module_orders: ['order_id', 'id'],
stripe_payment_intents: ['payment_intent_id', 'id']
};
const descriptionKeyOverrides: Partial<Record<EntityType, string[]>> = {
orders: ['notes', 'reference', 'reg_1'],
bookings: ['notes', 'status', 'wash_type'],
bookings_new: ['notes', 'status', 'wash_type'],
order_bookings: ['note', 'reference', 'datetime'],
xlvask_usage_logs: ['Customer', 'Location', 'VehicleType', 'StartTime'],
xlvask_customers: ['email', 'city', 'address', 'vatnumber'],
motorapi_lookups: ['endpoint', 'license_plate', 'result'],
fxratesapi_conversion_rates: ['base', 'target', 'endpoint', 'result'],
customer_discounts: ['customer_number', 'percentage'],
module_config: ['description', 'value']
};
const keyFieldOverrides: Partial<Record<EntityType, FieldSelector[]>> = {
orders: fieldList(
field('Order ID', 'id', 'order_id'),
field('Reference', 'reference'),
field('Registration', 'reg_1'),
field('Customer', 'customer_id')
),
order_items: fieldList(
field('Order', 'order_id'),
field('Product', 'product_id'),
field('Reference', 'reference')
),
vehicles: fieldList(
field('Registration', 'reg', 'registration_number', 'registrationNumber'),
field('Customer', 'customer_number', 'customer_id'),
field('Vehicle ID', 'vehicleId', 'id')
),
invoices: fieldList(
field('Invoice ID', 'id'),
field('External ID', 'external_id'),
field('Customer', 'customer_number')
),
products: fieldList(
field('Price', 'price'),
field('Category', 'category'),
field('Wash', 'is_wash'),
field('Product ID', 'economic_product_id', 'id')
),
bookings: fieldList(
field('Date', 'date'),
field('Reference', 'reference_number'),
field('Reg #1', 'regNrTraekker'),
field('Status', 'status')
),
bookings_new: fieldList(
field('Date', 'date'),
field('Reference', 'reference_number'),
field('Reg #1', 'regNrTraekker'),
field('Status', 'status')
),
order_bookings: fieldList(
field('Date/Time', 'datetime'),
field('Reg #1', 'reg_1'),
field('Reference', 'reference', 'po'),
field('Items', 'items')
),
xlvask_usage_logs: fieldList(
field('Registration', 'RegistrationNumber'),
field('Location', 'Location'),
field('Start', 'StartTime'),
field('Status', 'FinishStatus'),
field('Customer', 'Customer')
),
xlvask_customers: fieldList(
field('Customer ID', 'customerId'),
field('Email', 'email'),
field('City', 'city'),
field('VAT', 'vatnumber')
),
xlvask_vehicles: fieldList(
field('Vehicle ID', 'vehicleId'),
field('Registration', 'registrationNumber'),
field('Customer ID', 'customerId'),
field('Active', 'active')
),
motorapi_lookups: fieldList(
field('License plate', 'license_plate'),
field('Endpoint', 'endpoint'),
field('Result', 'result')
),
fxratesapi_conversion_rates: fieldList(
field('Base', 'base'),
field('Target', 'target'),
field('Endpoint', 'endpoint'),
field('Result', 'result')
),
module_config: fieldList(
field('Key', 'key'),
field('Value', 'value'),
field('Environment', 'environment')
),
stripe_module_customers: fieldList(
field('Stripe customer', 'stripe_customer_id', 'customer_id'),
field('Customer #', 'customer_number'),
field('Email', 'email')
),
stripe_module_orders: fieldList(
field('Order', 'order_id'),
field('Amount', 'amount', 'total'),
field('Status', 'status')
),
stripe_payment_intents: fieldList(
field('Payment intent', 'payment_intent_id', 'id'),
field('Amount', 'amount'),
field('Status', 'status')
)
};
const normalizeText = (value: unknown): string => {
if (typeof value !== 'string') return '';
return value.replace(/\s+/g, ' ').trim();
};
export const humanizeEntityType = (value: string): string => value.replace(/_/g, ' ');
export const toNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
};
const toScalar = (value: unknown): string => {
if (value === null || typeof value === 'undefined') return '';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '';
if (typeof value === 'string') return normalizeText(value);
if (Array.isArray(value)) return value.length ? `${value.length} entries` : '';
if (typeof value === 'object') {
const keys = Object.keys(value as Record<string, unknown>);
return keys.length ? `${keys.length} fields` : '';
}
return '';
};
const MAX_JSON_PARSE_LENGTH = 120000;
const JSON_LIKE_RE = /^[\[{]/;
const parseJsonLike = (value: unknown): unknown | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_JSON_PARSE_LENGTH || !JSON_LIKE_RE.test(trimmed)) return null;
try {
return JSON.parse(trimmed);
} catch {
return null;
}
};
const summarizeParsedValue = (key: string, value: unknown): string => {
if (Array.isArray(value)) {
if (key === 'items') {
const names = value
.map((entry) => (entry && typeof entry === 'object' ? (entry as Record<string, unknown>).name : null))
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
.slice(0, 3)
.map((name) => normalizeText(name));
return names.length ? `${value.length} items: ${names.join(', ')}` : `${value.length} items`;
}
return `${value.length} entries`;
}
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
if (key === 'result') {
if (typeof record.registration_number === 'string') {
const vehicle = [record.make, record.model]
.map((part) => toScalar(part))
.filter(Boolean)
.join(' ');
return vehicle ? `${record.registration_number} / ${vehicle}` : String(record.registration_number);
}
if (typeof record.base === 'string' && record.rates && typeof record.rates === 'object') {
const rateCount = Object.keys(record.rates as Record<string, unknown>).length;
return `${record.base} rates (${rateCount})`;
}
}
const keys = Object.keys(record);
return keys.length ? `${keys.length} fields` : '';
};
const formatFieldValue = (key: string, value: unknown): string => {
const parsed = parseJsonLike(value);
if (parsed !== null) return summarizeParsedValue(key, parsed);
return toScalar(value);
};
export const normalizePayload = (payload: unknown): SearchPayload | null => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
return payload as SearchPayload;
};
const parsePayloadForInspector = (payload: SearchPayload | null): SearchPayload | null => {
if (!payload) return null;
const parsed: SearchPayload = {};
Object.entries(payload).forEach(([key, value]) => {
parsed[key] = parseJsonLike(value) ?? value;
});
return parsed;
};
const readPayloadValue = (payload: SearchPayload | null, keys: string[]): unknown => {
if (!payload) return null;
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
return payload[key];
}
}
return null;
};
const isTitleLowSignal = (result: SearchResult, title: string): boolean => {
if (!title) return true;
if (title === result.entity_id) return true;
const fallback = `${humanizeEntityType(result.entity_type)} #${result.entity_id}`;
return title.toLowerCase() === fallback.toLowerCase();
};
const extractTitle = (result: SearchResult, payload: SearchPayload | null, titleKeys: string[]): string => {
const resultTitle = normalizeText(result.title);
const payloadTitle = toScalar(readPayloadValue(payload, titleKeys));
if (isTitleLowSignal(result, resultTitle)) {
return payloadTitle || resultTitle || `${humanizeEntityType(result.entity_type)} #${result.entity_id}`;
}
return resultTitle;
};
const extractDescription = (result: SearchResult, payload: SearchPayload | null, descriptionKeys: string[]): string => {
const fromResult = normalizeText(result.description);
if (fromResult) return fromResult;
return toScalar(readPayloadValue(payload, descriptionKeys));
};
const extractDepartmentId = (result: SearchResult, payload: SearchPayload | null): number | null => {
if (typeof result.department_id === 'number') return result.department_id;
return (
toNumber(readPayloadValue(payload, ['department_id'])) ??
toNumber(readPayloadValue(payload, ['departmentId'])) ??
toNumber(readPayloadValue(payload, ['department']))
);
};
const extractCustomerNumber = (result: SearchResult, payload: SearchPayload | null): number | null => {
if (typeof result.customer_number === 'number') return result.customer_number;
return (
toNumber(readPayloadValue(payload, ['customer_number'])) ??
toNumber(readPayloadValue(payload, ['customerNumber'])) ??
toNumber(readPayloadValue(payload, ['customer_id'])) ??
toNumber(readPayloadValue(payload, ['CustomerId']))
);
};
const buildBadges = (result: SearchResult, payload: SearchPayload | null): SystemSearchBadge[] => {
const badges: SystemSearchBadge[] = [{ label: `Score ${result.score}`, type: 'is-info' }];
const customer = extractCustomerNumber(result, payload);
const department = extractDepartmentId(result, payload);
if (customer !== null) badges.push({ label: `Customer #${customer}`, type: 'is-success' });
if (department !== null) badges.push({ label: `Department #${department}`, type: 'is-warning' });
if (normalizeText(result.association_reason)) badges.push({ label: 'Association', type: 'is-light' });
return badges;
};
const buildKeyFields = (payload: SearchPayload | null, selectors: FieldSelector[]): SystemSearchKeyField[] => {
if (!payload) return [];
const fields: SystemSearchKeyField[] = [];
const seen = new Set<string>();
for (const selector of selectors) {
const raw = readPayloadValue(payload, selector.keys);
const value = formatFieldValue(selector.keys[0], raw);
if (!value) continue;
const dedupeKey = `${selector.label}:${value}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
fields.push({ label: selector.label, value });
if (fields.length >= 6) break;
}
return fields;
};
const buildGenericRecordRoute = (result: SearchResult, context?: SystemSearchNavigationResolveContext): RouteLocationRaw => {
const query: Record<string, string> = {
score: String(result.score)
};
if (context?.searchQuery && context.searchQuery.trim()) query.q = context.searchQuery.trim();
if (typeof result.department_id === 'number') query.department_id = String(result.department_id);
if (typeof result.customer_number === 'number') query.customer_number = String(result.customer_number);
return {
path: `/search/system/record/${encodeURIComponent(result.entity_type)}/${encodeURIComponent(result.entity_id)}`,
query,
state: {
systemSearchResult: JSON.parse(JSON.stringify(result)),
systemSearchQuery: context?.searchQuery ?? ''
}
};
};
const genericTarget = (
result: SearchResult,
context?: SystemSearchNavigationResolveContext
): SystemSearchNavigationTarget | null => {
if (context?.allowGenericFallback === false) return null;
return {
strategy: 'generic',
to: buildGenericRecordRoute(result, context)
};
};
const target = (strategy: SystemSearchNavigationStrategy, path: string): SystemSearchNavigationTarget => ({
strategy,
to: { path }
});
const embeddedRouteTarget = (payload: SearchPayload | null): SystemSearchNavigationTarget | null => {
const route = toScalar(readPayloadValue(payload, ['path', 'url', 'route', 'to']));
if (!route || !route.startsWith('/')) return null;
return target('deep-link', route);
};
const resolveOrders: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'id'])) || result.entity_id;
const departmentId = extractDepartmentId(result, payload);
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('deep-link', `/admin/${departmentId}/modules/pos/orders/${encodeURIComponent(orderId)}`);
}
if (access.canAccessUser) {
return target('deep-link', `/user/orders/${encodeURIComponent(orderId)}`);
}
if (access.canAccessSuperUser) {
return target('module', '/superuser/orders');
}
return genericTarget(result, context);
};
const resolveOrderItems: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload);
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'orderId']));
if (departmentId !== null && orderId && access.canAccessDepartment(departmentId)) {
return target('deep-link', `/admin/${departmentId}/modules/pos/orders/${encodeURIComponent(orderId)}`);
}
if (access.canAccessSuperUser) {
return target('module', '/superuser/orders');
}
return genericTarget(result, context);
};
const resolveVehicles: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const registration = toScalar(readPayloadValue(payload, ['reg', 'registration_number', 'registrationNumber'])) || result.entity_id;
if (access.canAccessSuperUser) {
return target('deep-link', `/superuser/vehicles/${encodeURIComponent(registration)}`);
}
if (access.canAccessUser) {
return target('module', '/user/vehicles');
}
return genericTarget(result, context);
};
const resolveInvoices: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const invoiceId = toNumber(result.entity_id) ?? toNumber(readPayloadValue(payload, ['id']));
if (access.canAccessSuperUser) {
return invoiceId
? target('deep-link', `/superuser/invoices/${invoiceId}`)
: target('module', '/superuser/invoices');
}
if (access.canAccessUser) {
return target('module', '/user/invoices');
}
return genericTarget(result, context);
};
const resolveProducts: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (!access.canAccessSuperUser) return genericTarget(result, context);
const productId = toScalar(readPayloadValue(payload, ['id'])) || result.entity_id;
return target('deep-link', `/superuser/products/${encodeURIComponent(productId)}`);
};
const resolveDepartments: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload) ?? toNumber(result.entity_id);
if (departmentId !== null && access.canAccessSuperUser) {
return target('deep-link', `/superuser/departments/${departmentId}`);
}
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('deep-link', `/admin/${departmentId}`);
}
if (access.canAccessSuperUser) return target('module', '/superuser/departments');
if (access.canAccessAdmin) return target('module', '/admin');
return genericTarget(result, context);
};
const resolveRoles: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (!access.canAccessSuperUser) return genericTarget(result, context);
const roleId = toScalar(readPayloadValue(payload, ['id'])) || result.entity_id;
return target('deep-link', `/superuser/roles/${encodeURIComponent(roleId)}`);
};
const resolveDepartmentModule = (modulePath: string): NavigationResolver => (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload);
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('module', `/admin/${departmentId}/${modulePath}`);
}
return genericTarget(result, context);
};
const resolveBookings: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload);
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('module', `/admin/${departmentId}/modules/bookings`);
}
if (access.canAccessUser) {
return target('module', '/user/bookings');
}
return genericTarget(result, context);
};
const resolveDepartmentLanes: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload);
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('module', `/admin/${departmentId}/modules/wash-lanes`);
}
if (access.canAccessSuperUser) {
return target('module', '/superuser/department/lanes');
}
return genericTarget(result, context);
};
const resolveDepartmentGates: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/department/gates');
return genericTarget(result, context);
};
const resolveDepartmentRelays: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/department/relays');
return genericTarget(result, context);
};
const resolveCategories: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/categories');
return genericTarget(result, context);
};
const resolveCustomers: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/customers');
if (access.canAccessUser) return target('module', '/user/profile');
return genericTarget(result, context);
};
const resolveSubusers: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessUser) return target('module', '/user/subusers');
return genericTarget(result, context);
};
const resolveSubuserGrants: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessUser) return target('module', '/user/subusers/grants');
return genericTarget(result, context);
};
const resolveScanners: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/scanners');
return genericTarget(result, context);
};
const resolveXLVaskCustomers: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask/customers');
return genericTarget(result, context);
};
const resolveXLVaskUsageLogs: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask/usagelogs');
return genericTarget(result, context);
};
const resolveXLVaskGeneric: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask');
return genericTarget(result, context);
};
const resolveMotorApi: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/motorapi');
return genericTarget(result, context);
};
const resolveFxRates: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/fxratesapi');
return genericTarget(result, context);
};
const resolveStripe: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/stripe');
return genericTarget(result, context);
};
const resolveModuleConfig: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/configuration');
return genericTarget(result, context);
};
const resolveGeneric: NavigationResolver = (result, _payload, _access, context) => genericTarget(result, context);
const resolveNavigationForType = (entityType: EntityType): NavigationResolver => {
switch (entityType) {
case 'orders':
return resolveOrders;
case 'order_items':
return resolveOrderItems;
case 'vehicles':
return resolveVehicles;
case 'invoices':
return resolveInvoices;
case 'products':
return resolveProducts;
case 'departments':
return resolveDepartments;
case 'roles':
return resolveRoles;
case 'bookings':
case 'bookings_new':
case 'order_bookings':
return resolveBookings;
case 'department_selfserve_questions':
return resolveDepartmentModule('modules/self-serve/questions');
case 'department_selfserve_tasks':
return resolveDepartmentModule('modules/self-serve/tasks');
case 'department_selfserve_conditions':
return resolveDepartmentModule('modules/self-serve/conditions');
case 'department_selfserve_condition_rules':
return resolveDepartmentModule('modules/self-serve/condition-rules');
case 'department_selfserve_vehicle_conditions':
return resolveDepartmentModule('modules/self-serve/vehicle-conditions');
case 'department_time_bookings_entries':
return resolveDepartmentModule('modules/time-bookings');
case 'department_time_bookings_opening_hours':
return resolveDepartmentModule('modules/time-bookings/opening-hours');
case 'department_time_bookings_types':
return resolveDepartmentModule('modules/time-bookings/types');
case 'department_daily_reports':
return resolveDepartmentModule('modules/daily-report');
case 'department_goals':
return resolveDepartmentModule('modules/goals');
case 'department_notification_sms':
return resolveDepartmentModule('modules/notifications');
case 'department_lanes':
return resolveDepartmentLanes;
case 'department_gates':
return resolveDepartmentGates;
case 'department_relays':
return resolveDepartmentRelays;
case 'categories':
return resolveCategories;
case 'customers':
return resolveCustomers;
case 'subusers':
return resolveSubusers;
case 'subuser_grants':
return resolveSubuserGrants;
case 'plate_scanners':
case 'plate_scans':
return resolveScanners;
case 'xlvask_customers':
return resolveXLVaskCustomers;
case 'xlvask_usage_logs':
return resolveXLVaskUsageLogs;
case 'xlvask_vehicles':
case 'xlvask_vehicle_types':
return resolveXLVaskGeneric;
case 'motorapi_lookups':
return resolveMotorApi;
case 'fxratesapi_conversion_rates':
case 'currency_conversion_rates':
return resolveFxRates;
case 'stripe_module_customers':
case 'stripe_module_orders':
case 'stripe_payment_intents':
return resolveStripe;
case 'module_config':
return resolveModuleConfig;
default:
return resolveGeneric;
}
};
const resolveNavigationStrategy = (entityType: EntityType): SystemSearchNavigationStrategy => {
if (deepLinkTypes.has(entityType)) return 'deep-link';
if (moduleTypes.has(entityType)) return 'module';
return 'generic';
};
const buildDefinition = (entityType: EntityType): SystemSearchSupportDefinition => ({
entityType,
navigationStrategy: resolveNavigationStrategy(entityType),
titleKeys: titleKeyOverrides[entityType] ?? defaultTitleKeys,
descriptionKeys: descriptionKeyOverrides[entityType] ?? defaultDescriptionKeys,
keyFieldSelectors: keyFieldOverrides[entityType] ?? defaultKeyFields,
resolveNavigation: resolveNavigationForType(entityType)
});
export const SYSTEM_SEARCH_SUPPORT_REGISTRY: Record<EntityType, SystemSearchSupportDefinition> =
Object.fromEntries(SYSTEM_SEARCH_ENTITY_TYPES.map((entityType) => [entityType, buildDefinition(entityType)])) as Record<EntityType, SystemSearchSupportDefinition>;
export const resolveSystemSearchNavigationTarget = (
result: SearchResult,
access: SystemSearchAccessContext,
context?: SystemSearchNavigationResolveContext
): SystemSearchNavigationTarget | null => {
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[result.entity_type];
const payload = normalizePayload(result.payload);
return definition.resolveNavigation(result, payload, access, context);
};
export const buildSystemSearchViewModel = (result: SearchResult): SystemSearchViewModel => {
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[result.entity_type];
const payload = normalizePayload(result.payload);
const title = extractTitle(result, payload, definition.titleKeys);
const description = extractDescription(result, payload, definition.descriptionKeys);
const keyFields = buildKeyFields(payload, definition.keyFieldSelectors);
return {
title,
description,
badges: buildBadges(result, payload),
keyFields,
rawPayload: payload,
parsedPayload: parsePayloadForInspector(payload)
};
};
+576 -576
View File
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SessionUser from '@/components/session/token/SessionUser.vue';
import {
SYSTEM_SEARCH_ENTITY_TYPES,
buildSystemSearchViewModel,
humanizeEntityType,
normalizePayload,
resolveSystemSearchNavigationTarget,
toNumber,
type EntityType,
type SearchResult,
type SystemSearchAccessContext
} from '@/components/viewport/page/headers/menu/systemSearchSupport';
type SearchResponse = {
success: boolean;
data?: {
results?: SearchResult[];
};
};
type HistoryStateRecord = {
systemSearchResult?: SearchResult;
systemSearchQuery?: string;
usr?: {
systemSearchResult?: SearchResult;
systemSearchQuery?: string;
};
};
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const record = ref<SearchResult | null>(null);
const source = ref<'state' | 'lookup' | 'none'>('none');
const requestedEntityType = computed(() => String(route.params.entityType ?? '').trim());
const requestedEntityId = computed(() => String(route.params.entityId ?? '').trim());
const routeQueryText = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''));
const entityType = computed<EntityType | null>(() => (
SYSTEM_SEARCH_ENTITY_TYPES.includes(requestedEntityType.value as EntityType)
? requestedEntityType.value as EntityType
: null
));
const accessContext = computed<SystemSearchAccessContext>(() => ({
canAccessSuperUser: SessionUser.canAccessSuperUser(),
canAccessAdmin: SessionUser.canAccessAdmin(),
canAccessUser: SessionUser.canAccessUser(),
canAccessDepartment: (departmentId: number) => SessionUser.canAccessDepartment(departmentId)
}));
const parseErr = (e: unknown) => {
const parsed = SessionUser.functions.parseErrorMessage(e);
if (typeof parsed === 'string') return parsed;
if (parsed && typeof parsed === 'object') return JSON.stringify(parsed);
return 'Request failed.';
};
const isSearchResult = (value: unknown): value is SearchResult => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.entity_type === 'string'
&& typeof candidate.entity_id === 'string'
&& typeof candidate.title === 'string'
);
};
const readRouteStateRecord = (): SearchResult | null => {
const routeState = (route as unknown as { state?: HistoryStateRecord }).state;
const historyState = window.history.state as HistoryStateRecord | null;
const candidates = [
routeState?.systemSearchResult,
historyState?.systemSearchResult,
historyState?.usr?.systemSearchResult
];
for (const candidate of candidates) {
if (!isSearchResult(candidate)) continue;
if (candidate.entity_type !== requestedEntityType.value) continue;
if (String(candidate.entity_id) !== requestedEntityId.value) continue;
return candidate;
}
return null;
};
const pickBestLookupMatch = (rows: SearchResult[], type: EntityType, entityId: string): SearchResult | null => {
const exact = rows
.filter((row) => row.entity_type === type && String(row.entity_id) === entityId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
if (exact.length > 0) return exact[0];
const payloadIdMatches = rows
.filter((row) => {
if (row.entity_type !== type) return false;
const payload = normalizePayload(row.payload);
if (!payload) return false;
return String(payload.id ?? '') === entityId;
})
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
if (payloadIdMatches.length > 0) return payloadIdMatches[0];
const firstForType = rows.find((row) => row.entity_type === type);
return firstForType ?? null;
};
const loadFromLookup = async () => {
if (!entityType.value || !requestedEntityId.value) return;
loading.value = true;
error.value = '';
try {
const body = {
query: requestedEntityId.value,
include_types: [entityType.value],
include_associations: true,
debug_intent: false,
limit: 50,
offset: 0
};
const res = await SessionUser.request('/search/system', 'POST', body);
const payload = (res?.data as SearchResponse | undefined)?.data;
const rows = Array.isArray(payload?.results) ? payload.results : [];
const match = pickBestLookupMatch(rows, entityType.value, requestedEntityId.value);
if (!match) {
source.value = 'none';
error.value = 'No matching result found for this entity type.';
return;
}
record.value = match;
source.value = 'lookup';
} catch (e) {
source.value = 'none';
error.value = parseErr(e);
} finally {
loading.value = false;
}
};
const loadRecord = async () => {
record.value = null;
source.value = 'none';
error.value = '';
if (!entityType.value) {
error.value = `Unknown entity type: ${requestedEntityType.value}`;
return;
}
const fromState = readRouteStateRecord();
if (fromState) {
record.value = fromState;
source.value = 'state';
return;
}
await loadFromLookup();
};
const viewModel = computed(() => (record.value ? buildSystemSearchViewModel(record.value) : null));
const navigationTarget = computed(() => (
record.value
? resolveSystemSearchNavigationTarget(record.value, accessContext.value, { allowGenericFallback: false })
: null
));
const quickLinks = computed(() => {
const links: Array<{ label: string; path: string }> = [];
const active = record.value;
if (!active) return links;
const navTo = navigationTarget.value?.to as { path?: string } | undefined;
if (navTo?.path) links.push({ label: 'Primary location', path: navTo.path });
const payload = normalizePayload(active.payload);
const departmentId = typeof active.department_id === 'number'
? active.department_id
: toNumber(payload?.department ?? payload?.department_id ?? payload?.departmentId);
if (departmentId && SessionUser.canAccessDepartment(departmentId)) {
links.push({ label: `Department ${departmentId}`, path: `/admin/${departmentId}` });
}
if (SessionUser.canAccessSuperUser()) {
links.push({ label: 'Superuser home', path: '/superuser' });
} else if (SessionUser.canAccessAdmin()) {
links.push({ label: 'Department home', path: '/admin' });
} else if (SessionUser.canAccessUser()) {
links.push({ label: 'User home', path: '/user' });
}
return links.filter((item, index, all) => all.findIndex((entry) => entry.path === item.path) === index);
});
const asJson = (value: unknown) => {
try { return JSON.stringify(value ?? {}, null, 2); } catch { return '{}'; }
};
const openQuickLink = async (path: string) => {
await router.push(path);
};
watch(() => [route.params.entityType, route.params.entityId], () => {
void loadRecord();
});
onMounted(() => {
void loadRecord();
});
</script>
<template>
<section class="section system-search-record-page">
<div class="container is-max-desktop">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-3">
<div>
<h1 class="title is-5 mb-1">System Search Record</h1>
<p class="is-size-7 has-text-grey mb-0">{{ humanizeEntityType(requestedEntityType || 'unknown') }} / {{ requestedEntityId }}</p>
</div>
<b-button size="is-small" type="is-light" icon-left="arrow-left" icon-pack="fas" @click="router.back()">Back</b-button>
</div>
<p class="is-size-7 has-text-grey mb-3">
Source: <strong>{{ source }}</strong>
<span v-if="routeQueryText"> | Query context: "{{ routeQueryText }}"</span>
</p>
<p v-if="error" class="help is-danger mb-3">{{ error }}</p>
<div v-if="loading" class="box">
<p class="is-size-7 mb-0">Loading record...</p>
</div>
<div v-else-if="record && viewModel" class="box p-4">
<div class="is-flex is-justify-content-space-between is-align-items-start mb-2">
<p class="is-size-6 has-text-weight-semibold mb-0">{{ viewModel.title }}</p>
<b-tag type="is-info" size="is-small" rounded>{{ humanizeEntityType(record.entity_type) }}</b-tag>
</div>
<p v-if="viewModel.description" class="is-size-7 has-text-grey-dark mb-2">{{ viewModel.description }}</p>
<div class="record-badges mb-2">
<b-tag
v-for="badge in viewModel.badges"
:key="`badge-${badge.label}`"
:type="badge.type"
size="is-small"
rounded
class="mr-1 mb-1"
>
{{ badge.label }}
</b-tag>
</div>
<div v-if="viewModel.keyFields.length" class="mb-3">
<p v-for="field in viewModel.keyFields" :key="`field-${field.label}`" class="is-size-7 mb-1">
<strong>{{ field.label }}:</strong> {{ field.value }}
</p>
</div>
<div v-if="quickLinks.length" class="mb-3">
<p class="menu-label mb-2">Quick Links</p>
<b-button
v-for="link in quickLinks"
:key="`link-${link.path}`"
size="is-small"
type="is-light"
class="mr-2 mb-2"
@click="openQuickLink(link.path)"
>
{{ link.label }}
</b-button>
</div>
<details>
<summary class="is-size-7 has-text-weight-semibold">Payload inspector</summary>
<pre class="payload-pre mt-2">{{ asJson(viewModel.parsedPayload ?? viewModel.rawPayload) }}</pre>
</details>
</div>
<div v-else class="box">
<p class="is-size-7 mb-0">No record loaded.</p>
</div>
</div>
</section>
</template>
<style scoped>
.record-badges {
display: flex;
flex-wrap: wrap;
}
.payload-pre {
max-height: 420px;
overflow: auto;
padding: 10px;
border-radius: 6px;
background: #f6f6f6;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
aggregateDepartmentVisits,
aggregateOrderFrequency,
aggregateOrderItemFrequency,
buildOverviewStats,
buildUsageLogQuery,
buildVehiclesQuery,
normalizeListResponse,
normalizeRegistrationNumber,
normalizeRelatedOrders,
toIsoDateTimeWithoutTimezone,
uniqueCustomerIdsFromUsageLogs,
} from '@/views/dashboards/superUserDashboard/vehicle/imports/vehicleViewUtils.js';
describe('vehicle view utility query builders', () => {
it('builds usage log query with OpenAPI pagination params and optional filters', () => {
const date = new Date(2026, 1, 14, 9, 30, 15, 123);
const result = buildUsageLogQuery({
dateFrom: date,
regNr: 'AB12345',
customerId: '5001',
vehicleId: 'vehicle-guid',
page: 3,
perPage: 55,
});
expect(result.page).toBe(3);
expect(result.per_page).toBe(55);
expect(result.perPage).toBe(55);
expect(result.regNr).toBe('AB12345');
expect(result.customerId).toBe('5001');
expect(result.vehicleId).toBe('vehicle-guid');
expect(result.dateFrom).toBe('2026-02-14T09:30:15.123');
});
it('builds vehicles query with reg/customer/id and OpenAPI pagination params', () => {
const result = buildVehiclesQuery({
id: 7,
reg: 'CD67890',
customerId: 9999,
page: 2,
perPage: 40,
});
expect(result).toEqual({
id: 7,
reg: 'CD67890',
customer_id: 9999,
page: 2,
per_page: 40,
perPage: 40,
});
});
});
describe('vehicle view utility normalization', () => {
it('normalizes registration number safely and uppercases', () => {
expect(normalizeRegistrationNumber('ab 123')).toBe('AB 123');
expect(normalizeRegistrationNumber('AB%20123')).toBe('AB 123');
expect(normalizeRegistrationNumber(null)).toBe('');
});
it('formats date without timezone in expected precision', () => {
const value = toIsoDateTimeWithoutTimezone(new Date(2026, 0, 5, 6, 7, 8, 9));
expect(value).toBe('2026-01-05T06:07:08.009');
});
it('normalizes API list shapes', () => {
expect(normalizeListResponse({ data: { data: [{ id: 1 }] } })).toEqual([{ id: 1 }]);
expect(normalizeListResponse({ data: [{ id: 2 }] })).toEqual([{ id: 2 }]);
expect(normalizeListResponse({ data: { data: { id: 3 } } })).toEqual([{ id: 3 }]);
expect(normalizeListResponse(null)).toEqual([]);
});
it('normalizes related order maps', () => {
expect(normalizeRelatedOrders({ data: { data: { washA: [1, 2] } } })).toEqual({ washA: [1, 2] });
expect(normalizeRelatedOrders({ data: { data: [] } })).toEqual({});
});
});
describe('vehicle view analytics aggregations', () => {
const usageLogs = [
{
WashId: 'wash-1',
StartTime: '2026-03-01T08:00:00.000Z',
CustomerId: '1001',
Location: 'Hvidovre',
WashItems: [
{ OriginalProductName: 'Top Wash', Count: 1 },
{ OriginalProductName: 'Spot Free', Count: 2 },
],
},
{
WashId: 'wash-2',
StartTime: '2026-03-01T09:30:00.000Z',
CustomerId: '1002',
Location: 'Hvidovre',
WashItems: [
{ OriginalProductName: 'Top Wash', Count: 1 },
],
},
{
WashId: 'wash-3',
StartTime: '2026-03-02T12:30:00.000Z',
CustomerId: '1001',
Location: 'Taastrup',
WashItems: [
{ OriginalProductName: 'Chassis', Count: 3 },
],
},
];
const relatedOrders = {
'wash-1': [5001],
'wash-2': [5002, 5003],
'wash-3': [],
};
it('aggregates order frequency by day', () => {
const result = aggregateOrderFrequency(usageLogs, relatedOrders);
expect(result).toEqual([
{ label: '2026-03-01', value: 3 },
]);
});
it('aggregates order item frequency with ranking', () => {
const result = aggregateOrderItemFrequency(usageLogs, 5);
expect(result).toEqual([
{ label: 'Chassis', value: 3 },
{ label: 'Spot Free', value: 2 },
{ label: 'Top Wash', value: 2 },
]);
});
it('aggregates department visits', () => {
const result = aggregateDepartmentVisits(usageLogs);
expect(result).toEqual([
{ label: 'Hvidovre', value: 2 },
{ label: 'Taastrup', value: 1 },
]);
});
it('extracts related unique customer ids', () => {
expect(uniqueCustomerIdsFromUsageLogs(usageLogs).sort()).toEqual(['1001', '1002']);
});
it('builds overview statistics from usage logs and related orders', () => {
const result = buildOverviewStats(usageLogs, relatedOrders);
expect(result.totalWashes).toBe(3);
expect(result.uniqueCustomers).toBe(2);
expect(result.linkedOrders).toBe(3);
expect(result.uniqueDepartments).toBe(2);
expect(result.latestWashAt).toContain('2026-03-02');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = process.cwd();
const routerSource = readFileSync(join(root, 'src/router.js'), 'utf8');
const vehicleViewSource = readFileSync(
join(root, 'src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue'),
'utf8'
);
const vehicleUtilsSource = readFileSync(
join(root, 'src/views/dashboards/superUserDashboard/vehicle/imports/vehicleViewUtils.js'),
'utf8'
);
describe('superuser vehicle route contract', () => {
it('maps vehicle details route to registrationnumber path parameter', () => {
expect(routerSource).toContain("name: 'vehiclesvehicle'");
expect(routerSource).toContain("path: '/superuser/vehicles/:registrationnumber'");
expect(routerSource).toContain('component: Vehicle');
});
});
describe('superuser vehicle tabbed view contract', () => {
it('renders all required tabs using buefy tabs', () => {
expect(vehicleViewSource).toContain('<b-tabs');
expect(vehicleViewSource).toContain('label="Overview"');
expect(vehicleViewSource).toContain('label="Washes"');
expect(vehicleViewSource).toContain('label="Customers"');
expect(vehicleViewSource).toContain('label="Vehicle details"');
expect(vehicleViewSource).toContain('label="Diagrams"');
});
it('uses OpenAPI-aligned endpoints for vehicle intelligence data', () => {
expect(vehicleViewSource).toContain("'/vehicles'");
expect(vehicleViewSource).toContain("'/vehicles/status'");
expect(vehicleViewSource).toContain("'/vehicles/search'");
expect(vehicleViewSource).toContain("'/modules/xlvask/usageLog'");
expect(vehicleViewSource).toContain("'/modules/xlvask/vehicles'");
expect(vehicleViewSource).toContain("'/modules/xlvask/customers'");
expect(vehicleViewSource).toContain("'/superuser/users-with-vehicle-subscriptions'");
});
it('includes buefy table/filter controls across washes/customers/details', () => {
expect(vehicleViewSource).toContain('<b-table');
expect(vehicleViewSource).toContain('<b-field');
expect(vehicleViewSource).toContain('<b-input');
expect(vehicleViewSource).toContain('<b-select');
expect(vehicleViewSource).toContain('Apply filters');
expect(vehicleViewSource).toContain('Refresh details');
});
it('renders all requested diagrams', () => {
expect(vehicleViewSource).toContain('title="Order frequency"');
expect(vehicleViewSource).toContain('title="Order item frequency"');
expect(vehicleViewSource).toContain('title="Department visits"');
expect(vehicleViewSource).toContain('<VehicleAnalyticsChart');
});
});
describe('superuser vehicle openapi filter/options contract', () => {
it('builds usage log and vehicles query objects with page/per_page and filters', () => {
expect(vehicleUtilsSource).toContain('buildUsageLogQuery');
expect(vehicleUtilsSource).toContain('buildVehiclesQuery');
expect(vehicleUtilsSource).toContain('per_page');
expect(vehicleUtilsSource).toContain('perPage');
expect(vehicleUtilsSource).toContain('page');
expect(vehicleUtilsSource).toContain('customer_id');
expect(vehicleUtilsSource).toContain('regNr');
expect(vehicleUtilsSource).toContain('vehicleId');
expect(vehicleUtilsSource).toContain('customerId');
expect(vehicleUtilsSource).toContain('dateFrom');
});
});
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = process.cwd();
const modalSource = readFileSync(
join(root, 'src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue'),
'utf8'
);
const supportSource = readFileSync(
join(root, 'src/components/viewport/page/headers/menu/systemSearchSupport.ts'),
'utf8'
);
describe('advanced system search modal UI contract', () => {
it('uses support registry to build typed cards and open targets', () => {
expect(modalSource).toContain('buildSystemSearchViewModel');
expect(modalSource).toContain('resolveSystemSearchNavigationTarget');
expect(modalSource).toContain('const visibleResultCards = computed');
expect(modalSource).toContain('v-for="card in visibleResultCards"');
expect(modalSource).toContain('card.viewModel.keyFields');
expect(modalSource).toContain('@click="openResult(card)"');
expect(modalSource).not.toContain('const resolvePath =');
});
it('renders null-safe payload inspector and badge/key-field structure', () => {
expect(modalSource).toContain('card.viewModel.parsedPayload ?? card.viewModel.rawPayload');
expect(modalSource).toContain('result-badges');
expect(modalSource).toContain('result-key-fields');
expect(supportSource).toContain('export const normalizePayload');
});
it('sends include/exclude type filters as arrays for both GET and POST requests', () => {
expect(modalSource).toContain('include_types?: EntityType[]');
expect(modalSource).toContain('exclude_types?: EntityType[]');
expect(modalSource).toContain('params.include_types = [...includeTypes.value]');
expect(modalSource).toContain('params.exclude_types = [...excludeTypes.value]');
expect(modalSource).toContain('body.include_types = [...includeTypes.value]');
expect(modalSource).toContain('body.exclude_types = [...excludeTypes.value]');
expect(modalSource).not.toContain("params.include_types = includeTypes.value.join(',')");
expect(modalSource).not.toContain("params.exclude_types = excludeTypes.value.join(',')");
});
});
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = process.cwd();
const routerSource = readFileSync(join(root, 'src/router.js'), 'utf8');
const recordPageSource = readFileSync(join(root, 'src/views/search/SystemSearchRecordPage.vue'), 'utf8');
describe('system search generic record route contract', () => {
it('registers authenticated generic record route in router', () => {
expect(routerSource).toContain("import SystemSearchRecordPage from \"@/views/search/SystemSearchRecordPage.vue\";");
expect(routerSource).toContain("name: 'systemsearchrecord'");
expect(routerSource).toContain("path: '/search/system/record/:entityType/:entityId'");
expect(routerSource).toContain('component: SystemSearchRecordPage');
expect(routerSource).toContain('meta: { middleware: authMiddleware }');
});
});
describe('system search generic record page lookup contract', () => {
it('hydrates from state and falls back to /search/system lookup', () => {
expect(recordPageSource).toContain('systemSearchResult');
expect(recordPageSource).toContain("SessionUser.request('/search/system', 'POST', body)");
expect(recordPageSource).toContain('include_types: [entityType.value]');
expect(recordPageSource).toContain('allowGenericFallback: false');
});
});
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import {
SYSTEM_SEARCH_ENTITY_TYPES,
SYSTEM_SEARCH_SUPPORT_REGISTRY,
buildSystemSearchViewModel,
resolveSystemSearchNavigationTarget,
} from '@/components/viewport/page/headers/menu/systemSearchSupport.ts';
const superuserContext = {
canAccessSuperUser: true,
canAccessAdmin: true,
canAccessUser: true,
canAccessDepartment: () => true,
};
const adminContext = {
canAccessSuperUser: false,
canAccessAdmin: true,
canAccessUser: false,
canAccessDepartment: (departmentId) => departmentId === 4,
};
const userContext = {
canAccessSuperUser: false,
canAccessAdmin: false,
canAccessUser: true,
canAccessDepartment: () => false,
};
const row = (entityType, overrides = {}) => ({
entity_type: entityType,
entity_id: '11',
title: '11',
score: 120,
...overrides,
});
describe('system search support registry coverage', () => {
it('contains exactly all 58 known entity types', () => {
expect(SYSTEM_SEARCH_ENTITY_TYPES).toHaveLength(58);
expect(new Set(SYSTEM_SEARCH_ENTITY_TYPES).size).toBe(58);
const registryKeys = Object.keys(SYSTEM_SEARCH_SUPPORT_REGISTRY).sort();
const entityKeys = [...SYSTEM_SEARCH_ENTITY_TYPES].sort();
expect(registryKeys).toEqual(entityKeys);
});
it('provides render and navigation strategy for every type', () => {
for (const entityType of SYSTEM_SEARCH_ENTITY_TYPES) {
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[entityType];
expect(definition).toBeTruthy();
expect(['deep-link', 'module', 'generic']).toContain(definition.navigationStrategy);
expect(definition.titleKeys.length).toBeGreaterThan(0);
expect(definition.descriptionKeys.length).toBeGreaterThan(0);
expect(definition.keyFieldSelectors.length).toBeGreaterThan(0);
const vm = buildSystemSearchViewModel(row(entityType, { payload: null }));
expect(typeof vm.title).toBe('string');
expect(Array.isArray(vm.badges)).toBe(true);
expect(Array.isArray(vm.keyFields)).toBe(true);
const nav = resolveSystemSearchNavigationTarget(row(entityType), superuserContext);
expect(nav).not.toBeNull();
}
});
});
describe('system search navigation resolver behavior', () => {
it('deep-links order details for admin/superuser with department context', () => {
const nav = resolveSystemSearchNavigationTarget(
row('orders', {
entity_id: '34455',
payload: { id: 34455, department: '4' },
}),
adminContext
);
expect(nav.strategy).toBe('deep-link');
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
});
it('opens user order detail for user role', () => {
const nav = resolveSystemSearchNavigationTarget(
row('orders', { entity_id: '6078', payload: { id: 6078 } }),
userContext
);
expect(nav.to.path).toBe('/user/orders/6078');
});
it('falls back to generic detail when bookings are missing department context', () => {
const nav = resolveSystemSearchNavigationTarget(
row('bookings', { payload: null }),
adminContext
);
expect(nav.strategy).toBe('generic');
expect(nav.to.path).toContain('/search/system/record/bookings/11');
});
it('routes department self-serve entities to module pages when department is known', () => {
const nav = resolveSystemSearchNavigationTarget(
row('department_selfserve_questions', {
payload: { id: '19', department: '4' },
}),
adminContext
);
expect(nav.strategy).toBe('module');
expect(nav.to.path).toBe('/admin/4/modules/self-serve/questions');
});
it('routes customers and vehicles based on user role context', () => {
const customerForSuper = resolveSystemSearchNavigationTarget(row('customers'), superuserContext);
const customerForUser = resolveSystemSearchNavigationTarget(row('customers'), userContext);
const vehicleForSuper = resolveSystemSearchNavigationTarget(
row('vehicles', { payload: { registration_number: 'AB12345' } }),
superuserContext
);
const vehicleForUser = resolveSystemSearchNavigationTarget(
row('vehicles', { payload: { registration_number: 'AB12345' } }),
userContext
);
expect(customerForSuper.to.path).toBe('/superuser/customers');
expect(customerForUser.to.path).toBe('/user/profile');
expect(vehicleForSuper.to.path).toBe('/superuser/vehicles/AB12345');
expect(vehicleForUser.to.path).toBe('/user/vehicles');
});
it('routes xlvask and config/module types to their module pages with superuser access', () => {
const usage = resolveSystemSearchNavigationTarget(row('xlvask_usage_logs'), superuserContext);
const moduleConfig = resolveSystemSearchNavigationTarget(row('module_config'), superuserContext);
const fxrates = resolveSystemSearchNavigationTarget(row('fxratesapi_conversion_rates'), superuserContext);
expect(usage.to.path).toBe('/superuser/xlvask/usagelogs');
expect(moduleConfig.to.path).toBe('/superuser/configuration');
expect(fxrates.to.path).toBe('/superuser/configuration/fxratesapi');
});
it('handles missing payload safely and still opens generic detail', () => {
const nav = resolveSystemSearchNavigationTarget(
row('module_action_logs', { payload: null }),
userContext
);
expect(nav.strategy).toBe('generic');
expect(nav.to.path).toContain('/search/system/record/module_action_logs/11');
});
});
describe('system search card view-model behavior', () => {
it('creates readable key-field summaries for json-like payload strings', () => {
const vm = buildSystemSearchViewModel(row('order_bookings', {
payload: {
items: '[{\"id\": 10, \"name\": \"Indvendig vask Trailer\"}, {\"id\": 41, \"name\": \"Safety Seal\"}]',
datetime: '2025-11-24 23:00:00',
reg_1: 'FC2861',
},
}));
const itemsField = vm.keyFields.find((entry) => entry.label === 'Items');
expect(itemsField.value).toContain('2 items');
expect(vm.parsedPayload.items).toBeTypeOf('object');
});
});