diff --git a/composer.json b/composer.json index c836d4b80..7e1eb5748 100644 --- a/composer.json +++ b/composer.json @@ -90,6 +90,10 @@ "@test:coverage:clover", "@coverage:summary" ], + "coverage:check": [ + "@test:coverage:clover", + "php scripts/coverage-summary.php coverage/clover.xml --fail-under=100" + ], "coverage:summary": "php scripts/coverage-summary.php coverage/clover.xml", "lint": "php-cs-fixer fix -v", "test:lint": "php-cs-fixer fix -v --dry-run", diff --git a/scripts/coverage-summary.php b/scripts/coverage-summary.php index 09cda8782..aada86af7 100644 --- a/scripts/coverage-summary.php +++ b/scripts/coverage-summary.php @@ -2,7 +2,20 @@ declare(strict_types=1); -$cloverPath = $argv[1] ?? 'coverage/clover.xml'; +$cloverPath = 'coverage/clover.xml'; +$failUnder = null; + +foreach (array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--fail-under=')) { + $failUnder = (float) substr($arg, strlen('--fail-under=')); + + continue; + } + + if ($arg !== '') { + $cloverPath = $arg; + } +} if (!is_file($cloverPath)) { fwrite(STDERR, "Coverage file not found: {$cloverPath}\n"); @@ -108,3 +121,8 @@ function intMetric(SimpleXMLElement $node, string $name): int $relativePath = preg_replace('#^' . preg_quote(getcwd(), '#') . '/?#', '', $file['path']); printf(" %6.2f%% %5d/%-5d %s\n", $file['percent'], $file['covered'], $file['statements'], $relativePath ?: $file['path']); } + +if ($failUnder !== null && coveragePercent($coveredStatements, $statements) < $failUnder) { + fwrite(STDERR, sprintf("\nCoverage %.2f%% is below the required %.2f%% line threshold.\n", coveragePercent($coveredStatements, $statements), $failUnder)); + exit(1); +} diff --git a/server/src/Events/DriverLocationChanged.php b/server/src/Events/DriverLocationChanged.php index a7fc5e80e..7e9a59349 100644 --- a/server/src/Events/DriverLocationChanged.php +++ b/server/src/Events/DriverLocationChanged.php @@ -6,7 +6,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/DriverSimulatedLocationChanged.php b/server/src/Events/DriverSimulatedLocationChanged.php index b88651904..6d66de149 100644 --- a/server/src/Events/DriverSimulatedLocationChanged.php +++ b/server/src/Events/DriverSimulatedLocationChanged.php @@ -7,7 +7,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/EntityActivityChanged.php b/server/src/Events/EntityActivityChanged.php index c09349700..b2d8ec74d 100644 --- a/server/src/Events/EntityActivityChanged.php +++ b/server/src/Events/EntityActivityChanged.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/EntityCompleted.php b/server/src/Events/EntityCompleted.php index 75463afe9..57dc6f2b6 100644 --- a/server/src/Events/EntityCompleted.php +++ b/server/src/Events/EntityCompleted.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/EntityDriverAssigned.php b/server/src/Events/EntityDriverAssigned.php index dce26a679..fb677b605 100644 --- a/server/src/Events/EntityDriverAssigned.php +++ b/server/src/Events/EntityDriverAssigned.php @@ -5,7 +5,7 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; class EntityDriverAssigned implements ShouldBroadcast diff --git a/server/src/Events/FuelProviderTransactionImported.php b/server/src/Events/FuelProviderTransactionImported.php index 52d35845c..1119eadd3 100644 --- a/server/src/Events/FuelProviderTransactionImported.php +++ b/server/src/Events/FuelProviderTransactionImported.php @@ -3,7 +3,7 @@ namespace Fleetbase\FleetOps\Events; use Fleetbase\FleetOps\Models\FuelProviderTransaction; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; class FuelProviderTransactionImported diff --git a/server/src/Events/FuelProviderTransactionMatched.php b/server/src/Events/FuelProviderTransactionMatched.php index 40eab7d96..4250dd53d 100644 --- a/server/src/Events/FuelProviderTransactionMatched.php +++ b/server/src/Events/FuelProviderTransactionMatched.php @@ -3,7 +3,7 @@ namespace Fleetbase\FleetOps\Events; use Fleetbase\FleetOps\Models\FuelProviderTransaction; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; class FuelProviderTransactionMatched diff --git a/server/src/Events/FuelProviderTransactionUnmatched.php b/server/src/Events/FuelProviderTransactionUnmatched.php index c1f82dfb5..a0b0d895a 100644 --- a/server/src/Events/FuelProviderTransactionUnmatched.php +++ b/server/src/Events/FuelProviderTransactionUnmatched.php @@ -3,7 +3,7 @@ namespace Fleetbase\FleetOps\Events; use Fleetbase\FleetOps\Models\FuelProviderTransaction; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; class FuelProviderTransactionUnmatched diff --git a/server/src/Events/FuelReportCreatedFromProvider.php b/server/src/Events/FuelReportCreatedFromProvider.php index c8836440b..15f752c70 100644 --- a/server/src/Events/FuelReportCreatedFromProvider.php +++ b/server/src/Events/FuelReportCreatedFromProvider.php @@ -4,7 +4,7 @@ use Fleetbase\FleetOps\Models\FuelProviderTransaction; use Fleetbase\FleetOps\Models\FuelReport; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; class FuelReportCreatedFromProvider diff --git a/server/src/Events/GeofenceDwelled.php b/server/src/Events/GeofenceDwelled.php index d4b1d5d61..cbaae7832 100644 --- a/server/src/Events/GeofenceDwelled.php +++ b/server/src/Events/GeofenceDwelled.php @@ -7,7 +7,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; /** diff --git a/server/src/Events/GeofenceEntered.php b/server/src/Events/GeofenceEntered.php index 669e9913e..78ce3e61d 100644 --- a/server/src/Events/GeofenceEntered.php +++ b/server/src/Events/GeofenceEntered.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; /** diff --git a/server/src/Events/GeofenceExited.php b/server/src/Events/GeofenceExited.php index d4d2c1be0..31848d7e0 100644 --- a/server/src/Events/GeofenceExited.php +++ b/server/src/Events/GeofenceExited.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; /** diff --git a/server/src/Events/VehicleLocationChanged.php b/server/src/Events/VehicleLocationChanged.php index a20e54d91..d7c1fb76c 100644 --- a/server/src/Events/VehicleLocationChanged.php +++ b/server/src/Events/VehicleLocationChanged.php @@ -6,7 +6,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/WaypointActivityChanged.php b/server/src/Events/WaypointActivityChanged.php index 2467cb0bf..56c18955f 100644 --- a/server/src/Events/WaypointActivityChanged.php +++ b/server/src/Events/WaypointActivityChanged.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Events/WaypointCompleted.php b/server/src/Events/WaypointCompleted.php index 700b3e9f0..71a435e29 100644 --- a/server/src/Events/WaypointCompleted.php +++ b/server/src/Events/WaypointCompleted.php @@ -8,7 +8,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; diff --git a/server/src/Exceptions/TelematicRateLimitExceededException.php b/server/src/Exceptions/TelematicRateLimitExceededException.php index 2587c8c90..45ede052b 100644 --- a/server/src/Exceptions/TelematicRateLimitExceededException.php +++ b/server/src/Exceptions/TelematicRateLimitExceededException.php @@ -7,6 +7,6 @@ * * Exception thrown when provider rate limit is exceeded. */ -class TelematicRateLimitExceededException extends ProviderException +class TelematicRateLimitExceededException extends TelematicProviderException { } diff --git a/server/src/Http/Requests/BulkDispatchRequest.php b/server/src/Http/Requests/BulkDispatchRequest.php index 2464b4350..61b9839bf 100644 --- a/server/src/Http/Requests/BulkDispatchRequest.php +++ b/server/src/Http/Requests/BulkDispatchRequest.php @@ -11,7 +11,7 @@ class BulkDispatchRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return $this->session()->has('user'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'ids' => ['required', 'array'], @@ -33,7 +33,7 @@ public function rules() * * @return array */ - public function messages() + public function messages(): array { return [ 'ids.required' => 'Please provide a resource ID.', diff --git a/server/src/Http/Requests/CancelOrderRequest.php b/server/src/Http/Requests/CancelOrderRequest.php index 83506a527..ea4ee7d9b 100644 --- a/server/src/Http/Requests/CancelOrderRequest.php +++ b/server/src/Http/Requests/CancelOrderRequest.php @@ -11,7 +11,7 @@ class CancelOrderRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'order' => 'required|exists:orders,uuid', diff --git a/server/src/Http/Requests/CreateContactRequest.php b/server/src/Http/Requests/CreateContactRequest.php index 5b60e563e..408bfc6e0 100644 --- a/server/src/Http/Requests/CreateContactRequest.php +++ b/server/src/Http/Requests/CreateContactRequest.php @@ -12,7 +12,7 @@ class CreateContactRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('storefront_key') || request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [new RequiredIf($this->isMethod('POST'))], diff --git a/server/src/Http/Requests/CreateDeviceRequest.php b/server/src/Http/Requests/CreateDeviceRequest.php index 8d41499e8..3c94f7735 100644 --- a/server/src/Http/Requests/CreateDeviceRequest.php +++ b/server/src/Http/Requests/CreateDeviceRequest.php @@ -8,12 +8,12 @@ class CreateDeviceRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 007499372..121a852be 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -13,7 +13,7 @@ class CreateDriverRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->is('navigator/v1/*') || request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { $isCreating = $this->isMethod('POST'); @@ -50,7 +50,7 @@ public function rules() * * @return array */ - public function attributes() + public function attributes(): array { return [ 'email' => 'email address', diff --git a/server/src/Http/Requests/CreateEntityRequest.php b/server/src/Http/Requests/CreateEntityRequest.php index 33fdb67ca..94acb7f0c 100644 --- a/server/src/Http/Requests/CreateEntityRequest.php +++ b/server/src/Http/Requests/CreateEntityRequest.php @@ -12,7 +12,7 @@ class CreateEntityRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST'))], diff --git a/server/src/Http/Requests/CreateEquipmentRequest.php b/server/src/Http/Requests/CreateEquipmentRequest.php index 77f495c0c..c2c9c429d 100644 --- a/server/src/Http/Requests/CreateEquipmentRequest.php +++ b/server/src/Http/Requests/CreateEquipmentRequest.php @@ -7,12 +7,12 @@ class CreateEquipmentRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateFleetRequest.php b/server/src/Http/Requests/CreateFleetRequest.php index 2233d1faf..6d4482d46 100644 --- a/server/src/Http/Requests/CreateFleetRequest.php +++ b/server/src/Http/Requests/CreateFleetRequest.php @@ -12,7 +12,7 @@ class CreateFleetRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST'))], diff --git a/server/src/Http/Requests/CreateFuelReportRequest.php b/server/src/Http/Requests/CreateFuelReportRequest.php index 878fb89de..fed0b488f 100644 --- a/server/src/Http/Requests/CreateFuelReportRequest.php +++ b/server/src/Http/Requests/CreateFuelReportRequest.php @@ -11,7 +11,7 @@ class CreateFuelReportRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'driver' => ['required'], diff --git a/server/src/Http/Requests/CreateFuelTransactionRequest.php b/server/src/Http/Requests/CreateFuelTransactionRequest.php index f056dadaf..ca91ca6eb 100644 --- a/server/src/Http/Requests/CreateFuelTransactionRequest.php +++ b/server/src/Http/Requests/CreateFuelTransactionRequest.php @@ -7,12 +7,12 @@ class CreateFuelTransactionRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'provider' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateIssueRequest.php b/server/src/Http/Requests/CreateIssueRequest.php index 64a06371a..38933d544 100644 --- a/server/src/Http/Requests/CreateIssueRequest.php +++ b/server/src/Http/Requests/CreateIssueRequest.php @@ -11,7 +11,7 @@ class CreateIssueRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'driver' => ['required'], diff --git a/server/src/Http/Requests/CreateOrderRequest.php b/server/src/Http/Requests/CreateOrderRequest.php index bb365761a..be822c94a 100644 --- a/server/src/Http/Requests/CreateOrderRequest.php +++ b/server/src/Http/Requests/CreateOrderRequest.php @@ -14,7 +14,7 @@ class CreateOrderRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -24,7 +24,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { $validations = [ 'adhoc' => ['nullable', 'boolean'], diff --git a/server/src/Http/Requests/CreatePartRequest.php b/server/src/Http/Requests/CreatePartRequest.php index bc186bdfb..a3a197af7 100644 --- a/server/src/Http/Requests/CreatePartRequest.php +++ b/server/src/Http/Requests/CreatePartRequest.php @@ -7,12 +7,12 @@ class CreatePartRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'sku' => ['nullable', 'string'], diff --git a/server/src/Http/Requests/CreatePayloadRequest.php b/server/src/Http/Requests/CreatePayloadRequest.php index 901c88cad..0a9864aa9 100644 --- a/server/src/Http/Requests/CreatePayloadRequest.php +++ b/server/src/Http/Requests/CreatePayloadRequest.php @@ -12,7 +12,7 @@ class CreatePayloadRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'entities' => 'array', diff --git a/server/src/Http/Requests/CreatePlaceRequest.php b/server/src/Http/Requests/CreatePlaceRequest.php index 75f5d2277..5dd3efc4b 100644 --- a/server/src/Http/Requests/CreatePlaceRequest.php +++ b/server/src/Http/Requests/CreatePlaceRequest.php @@ -14,7 +14,7 @@ class CreatePlaceRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -24,7 +24,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [ diff --git a/server/src/Http/Requests/CreatePurchaseRateRequest.php b/server/src/Http/Requests/CreatePurchaseRateRequest.php index 3232672b6..3e7692752 100644 --- a/server/src/Http/Requests/CreatePurchaseRateRequest.php +++ b/server/src/Http/Requests/CreatePurchaseRateRequest.php @@ -13,7 +13,7 @@ class CreatePurchaseRateRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'service_quote' => ['required', Rule::exists('service_quotes', 'public_id')->whereNull('deleted_at')], diff --git a/server/src/Http/Requests/CreateSensorRequest.php b/server/src/Http/Requests/CreateSensorRequest.php index 4eacb0a1d..4517dbaf3 100644 --- a/server/src/Http/Requests/CreateSensorRequest.php +++ b/server/src/Http/Requests/CreateSensorRequest.php @@ -8,12 +8,12 @@ class CreateSensorRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateServiceAreaRequest.php b/server/src/Http/Requests/CreateServiceAreaRequest.php index 5c105ce34..828e472bd 100644 --- a/server/src/Http/Requests/CreateServiceAreaRequest.php +++ b/server/src/Http/Requests/CreateServiceAreaRequest.php @@ -13,7 +13,7 @@ class CreateServiceAreaRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateServiceQuoteRequest.php b/server/src/Http/Requests/CreateServiceQuoteRequest.php index 3101dce46..b25284439 100644 --- a/server/src/Http/Requests/CreateServiceQuoteRequest.php +++ b/server/src/Http/Requests/CreateServiceQuoteRequest.php @@ -11,7 +11,7 @@ class CreateServiceQuoteRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return false; } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ ]; diff --git a/server/src/Http/Requests/CreateServiceRateRequest.php b/server/src/Http/Requests/CreateServiceRateRequest.php index 2dd58cbbf..6987dd44c 100644 --- a/server/src/Http/Requests/CreateServiceRateRequest.php +++ b/server/src/Http/Requests/CreateServiceRateRequest.php @@ -14,7 +14,7 @@ class CreateServiceRateRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -24,7 +24,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'service_name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateTrackingNumberRequest.php b/server/src/Http/Requests/CreateTrackingNumberRequest.php index 5b7d12013..355e93d1b 100644 --- a/server/src/Http/Requests/CreateTrackingNumberRequest.php +++ b/server/src/Http/Requests/CreateTrackingNumberRequest.php @@ -12,7 +12,7 @@ class CreateTrackingNumberRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'region' => 'required|string', diff --git a/server/src/Http/Requests/CreateTrackingStatusRequest.php b/server/src/Http/Requests/CreateTrackingStatusRequest.php index a8375fa44..0526a2774 100644 --- a/server/src/Http/Requests/CreateTrackingStatusRequest.php +++ b/server/src/Http/Requests/CreateTrackingStatusRequest.php @@ -16,7 +16,7 @@ class CreateTrackingStatusRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -26,7 +26,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { $validations = [ 'tracking_number' => array_filter([ diff --git a/server/src/Http/Requests/CreateVehicleRequest.php b/server/src/Http/Requests/CreateVehicleRequest.php index 0c517cbd6..3bdf5b438 100644 --- a/server/src/Http/Requests/CreateVehicleRequest.php +++ b/server/src/Http/Requests/CreateVehicleRequest.php @@ -13,7 +13,7 @@ class CreateVehicleRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'status' => [ diff --git a/server/src/Http/Requests/CreateVendorRequest.php b/server/src/Http/Requests/CreateVendorRequest.php index f4acd49ef..eec5e36d7 100644 --- a/server/src/Http/Requests/CreateVendorRequest.php +++ b/server/src/Http/Requests/CreateVendorRequest.php @@ -12,7 +12,7 @@ class CreateVendorRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -22,7 +22,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/CreateWorkOrderRequest.php b/server/src/Http/Requests/CreateWorkOrderRequest.php index 5fb935953..0b5ca84ae 100644 --- a/server/src/Http/Requests/CreateWorkOrderRequest.php +++ b/server/src/Http/Requests/CreateWorkOrderRequest.php @@ -7,12 +7,12 @@ class CreateWorkOrderRequest extends FleetbaseRequest { - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential') || request()->session()->has('is_sanctum_token'); } - public function rules() + public function rules(): array { return [ 'code' => ['nullable', 'string'], diff --git a/server/src/Http/Requests/CreateZoneRequest.php b/server/src/Http/Requests/CreateZoneRequest.php index ed7e35a08..64f7866fb 100644 --- a/server/src/Http/Requests/CreateZoneRequest.php +++ b/server/src/Http/Requests/CreateZoneRequest.php @@ -13,7 +13,7 @@ class CreateZoneRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], diff --git a/server/src/Http/Requests/DecodeTrackingNumberQR.php b/server/src/Http/Requests/DecodeTrackingNumberQR.php index 14e833eb1..a85e5f181 100644 --- a/server/src/Http/Requests/DecodeTrackingNumberQR.php +++ b/server/src/Http/Requests/DecodeTrackingNumberQR.php @@ -11,7 +11,7 @@ class DecodeTrackingNumberQR extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'code' => 'required|string', diff --git a/server/src/Http/Requests/DriverSimulationRequest.php b/server/src/Http/Requests/DriverSimulationRequest.php index 8071ce3de..8726ef9fd 100644 --- a/server/src/Http/Requests/DriverSimulationRequest.php +++ b/server/src/Http/Requests/DriverSimulationRequest.php @@ -13,7 +13,7 @@ class DriverSimulationRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'start' => [Rule::requiredIf( diff --git a/server/src/Http/Requests/Internal/AssignOrderRequest.php b/server/src/Http/Requests/Internal/AssignOrderRequest.php index 01c69cddf..5977d8127 100644 --- a/server/src/Http/Requests/Internal/AssignOrderRequest.php +++ b/server/src/Http/Requests/Internal/AssignOrderRequest.php @@ -11,7 +11,7 @@ class AssignOrderRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return session('company'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'order' => ['required', 'exists:orders,public_id'], diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php index 4a406116a..bac2949ed 100644 --- a/server/src/Http/Requests/Internal/CreateDriverRequest.php +++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php @@ -15,7 +15,7 @@ class CreateDriverRequest extends CreateDriverApiRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return Auth::can('fleet-ops create driver'); } @@ -25,7 +25,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { $isCreating = $this->isMethod('POST'); $isCreatingWithUser = $this->filled('driver.user_uuid'); @@ -70,7 +70,7 @@ public function rules() * * @return array */ - public function attributes() + public function attributes(): array { return [ 'name' => 'driver name', @@ -88,7 +88,7 @@ public function attributes() * * @return array */ - public function messages() + public function messages(): array { return [ 'name.required' => 'Driver name is required.', diff --git a/server/src/Http/Requests/Internal/CreateOrderConfigRequest.php b/server/src/Http/Requests/Internal/CreateOrderConfigRequest.php index de0ea3c8d..275904ef3 100644 --- a/server/src/Http/Requests/Internal/CreateOrderConfigRequest.php +++ b/server/src/Http/Requests/Internal/CreateOrderConfigRequest.php @@ -13,7 +13,7 @@ class CreateOrderConfigRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return Auth::can('fleet-ops create order-config'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'name' => [ diff --git a/server/src/Http/Requests/Internal/CreateOrderRequest.php b/server/src/Http/Requests/Internal/CreateOrderRequest.php index 6b77b32ad..eca993789 100644 --- a/server/src/Http/Requests/Internal/CreateOrderRequest.php +++ b/server/src/Http/Requests/Internal/CreateOrderRequest.php @@ -14,7 +14,7 @@ class CreateOrderRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return Auth::can('fleet-ops create order'); } @@ -24,7 +24,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { $validations = [ 'order_config_uuid' => ['required'], diff --git a/server/src/Http/Requests/Internal/FleetActionRequest.php b/server/src/Http/Requests/Internal/FleetActionRequest.php index 6705441a5..239149619 100644 --- a/server/src/Http/Requests/Internal/FleetActionRequest.php +++ b/server/src/Http/Requests/Internal/FleetActionRequest.php @@ -12,7 +12,7 @@ class FleetActionRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { $action = $this->route()->getActionMethod(); @@ -40,7 +40,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'fleet' => 'string|exists:fleets,uuid', diff --git a/server/src/Http/Requests/Internal/UpdateDriverRequest.php b/server/src/Http/Requests/Internal/UpdateDriverRequest.php index e1fac32cf..4d531a14f 100644 --- a/server/src/Http/Requests/Internal/UpdateDriverRequest.php +++ b/server/src/Http/Requests/Internal/UpdateDriverRequest.php @@ -11,7 +11,7 @@ class UpdateDriverRequest extends CreateDriverRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return Auth::can('fleet-ops update driver'); } diff --git a/server/src/Http/Requests/QueryServiceQuotesRequest.php b/server/src/Http/Requests/QueryServiceQuotesRequest.php index 4715e2cb6..69e3a42f8 100644 --- a/server/src/Http/Requests/QueryServiceQuotesRequest.php +++ b/server/src/Http/Requests/QueryServiceQuotesRequest.php @@ -13,7 +13,7 @@ class QueryServiceQuotesRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -23,7 +23,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'payload' => ['nullable', 'required_without_all:waypoints,pickup,dropoff', Rule::exists('payloads', 'public_id')->whereNull('deleted_at')], diff --git a/server/src/Http/Requests/ScheduleOrderRequest.php b/server/src/Http/Requests/ScheduleOrderRequest.php index be759b92e..6f4e7f481 100644 --- a/server/src/Http/Requests/ScheduleOrderRequest.php +++ b/server/src/Http/Requests/ScheduleOrderRequest.php @@ -11,7 +11,7 @@ class ScheduleOrderRequest extends FleetbaseRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return request()->session()->has('api_credential'); } @@ -21,7 +21,7 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { return [ 'date' => 'required|date_format:Y-m-d', diff --git a/server/src/Http/Requests/UpdateIssueRequest.php b/server/src/Http/Requests/UpdateIssueRequest.php index 06ac94059..0ddf05cea 100644 --- a/server/src/Http/Requests/UpdateIssueRequest.php +++ b/server/src/Http/Requests/UpdateIssueRequest.php @@ -9,7 +9,7 @@ class UpdateIssueRequest extends CreateIssueRequest * * @return array */ - public function rules() + public function rules(): array { return [ 'report' => ['required'], diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 6659d381d..6fdeb639e 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -492,7 +492,7 @@ public static function getPointFromCoordinatesStrict($coordinates): ?Point $coordinates = null; } - if (preg_match('/LatLng\(([^,]+),\s*([^)]+)\)/', $coordinates, $matches)) { + if (is_string($coordinates) && preg_match('/LatLng\(([^,]+),\s*([^)]+)\)/', $coordinates, $matches)) { $coords = [ floatval($matches[1]), floatval($matches[2]), @@ -501,20 +501,20 @@ public static function getPointFromCoordinatesStrict($coordinates): ?Point $coordinates = null; } - if (Str::contains($coordinates, ',')) { + if (is_string($coordinates) && Str::contains($coordinates, ',')) { $coords = explode(',', $coordinates); } - if (Str::contains($coordinates, '|')) { + if (is_string($coordinates) && Str::contains($coordinates, '|')) { $coords = explode('|', $coordinates); } - if (Str::contains($coordinates, ' ')) { + if (is_string($coordinates) && Str::contains($coordinates, ' ')) { $coords = explode(' ', $coordinates); } - $latitude = $coords[0]; - $longitude = $coords[1]; + $latitude = $coords[0] ?? null; + $longitude = $coords[1] ?? null; if (is_numeric($latitude) && is_numeric($longitude)) { $coordinates = new Point((float) $latitude, (float) $longitude); diff --git a/server/src/Tracking/TrackingProviderManager.php b/server/src/Tracking/TrackingProviderManager.php index 26c04b72a..e0c6e4e48 100644 --- a/server/src/Tracking/TrackingProviderManager.php +++ b/server/src/Tracking/TrackingProviderManager.php @@ -6,7 +6,6 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; class TrackingProviderManager { @@ -33,7 +32,7 @@ public function track(TrackingContext $context, TrackingOptions $options): Track $result = $this->trackWithProviderCache($provider, $context, $options); $result->warnings = array_values(array_unique([...$warnings, ...$result->warnings])); - if (Str::snake($result->provider) !== Str::snake((string) $options->provider)) { + if (TrackingProviderRegistry::normalizeKey($result->provider) !== TrackingProviderRegistry::normalizeKey($options->provider)) { $result->warnings[] = 'fallback_used'; } @@ -62,7 +61,7 @@ protected function providerOrder(TrackingOptions $options): array return collect([$provider, ...$fallbacks]) ->filter() - ->map(fn ($key) => Str::snake($key)) + ->map(fn ($key) => TrackingProviderRegistry::normalizeKey($key)) ->unique() ->values() ->all(); diff --git a/server/src/Tracking/TrackingProviderRegistry.php b/server/src/Tracking/TrackingProviderRegistry.php index dfe76846f..3ed5cc380 100644 --- a/server/src/Tracking/TrackingProviderRegistry.php +++ b/server/src/Tracking/TrackingProviderRegistry.php @@ -3,16 +3,20 @@ namespace Fleetbase\FleetOps\Tracking; use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface; -use Illuminate\Support\Str; class TrackingProviderRegistry { protected array $providers = []; + public static function normalizeKey(?string $key): string + { + return trim((string) preg_replace('/[^a-z0-9]+/', '_', strtolower((string) $key)), '_'); + } + public function register(TrackingProviderInterface|string $provider, ?string $key = null): self { $instance = is_string($provider) ? app($provider) : $provider; - $providerKey = Str::snake($key ?? $instance->key()); + $providerKey = static::normalizeKey($key ?? $instance->key()); $this->providers[$providerKey] = $instance; return $this; @@ -20,12 +24,12 @@ public function register(TrackingProviderInterface|string $provider, ?string $ke public function has(string $key): bool { - return isset($this->providers[Str::snake($key)]); + return isset($this->providers[static::normalizeKey($key)]); } public function get(string $key): ?TrackingProviderInterface { - return $this->providers[Str::snake($key)] ?? null; + return $this->providers[static::normalizeKey($key)] ?? null; } public function all(): array diff --git a/server/tests/AnalyticsRoutesTest.php b/server/tests/AnalyticsRoutesTest.php index 43903e1b2..4688bdaed 100644 --- a/server/tests/AnalyticsRoutesTest.php +++ b/server/tests/AnalyticsRoutesTest.php @@ -1,5 +1,26 @@ $this->company->uuid, + 'currency' => $this->companyCurrency(), + 'start' => $this->start?->format('Y-m-d'), + 'end' => $this->end?->format('Y-m-d'), + ]; + } +} + test('analytics routes are registered alongside metrics routes', function () { $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php'); @@ -49,3 +70,73 @@ ->toContain('TIMESTAMPDIFF(SECOND, orders.scheduled_at, orders.updated_at) <= 1800') ->not->toContain("'on_time' => 'on_time_pct'"); }); + +test('analytics base resolves shorthand explicit and default periods', function () { + Carbon::setTestNow(Carbon::parse('2026-07-19 12:00:00')); + + [$start7d, $end7d] = AbstractAnalytics::resolvePeriod('7d', null, null); + [$explicitStart, $explicitEnd] = AbstractAnalytics::resolvePeriod( + null, + Carbon::parse('2026-01-01'), + Carbon::parse('2026-02-01'), + ); + [$defaultStart, $defaultEnd] = AbstractAnalytics::resolvePeriod('unknown', null, null, 10); + + Carbon::setTestNow(); + + expect($start7d->format('Y-m-d'))->toBe('2026-07-12') + ->and($end7d->format('Y-m-d'))->toBe('2026-07-19') + ->and($explicitStart->format('Y-m-d'))->toBe('2026-01-01') + ->and($explicitEnd->format('Y-m-d'))->toBe('2026-02-01') + ->and($defaultStart->format('Y-m-d'))->toBe('2026-07-09') + ->and($defaultEnd->format('Y-m-d'))->toBe('2026-07-19'); +}); + +test('analytics base applies company currency and explicit date ranges', function () { + $company = new Company(); + $company->setRawAttributes([ + 'uuid' => 'company-1', + 'currency' => 'MNT', + ], true); + + $analytics = TestFleetOpsAnalytics::forCompany($company) + ->between(Carbon::parse('2026-03-01'), Carbon::parse('2026-03-31')); + + expect($analytics->get())->toBe([ + 'company' => 'company-1', + 'currency' => 'MNT', + 'start' => '2026-03-01', + 'end' => '2026-03-31', + ]); +}); + +test('analytics widget fluent options clamp or normalize invalid values', function () { + $limitProperty = new ReflectionProperty(TopDrivers::class, 'limit'); + $limitProperty->setAccessible(true); + $sortProperty = new ReflectionProperty(TopDrivers::class, 'sortBy'); + $sortProperty->setAccessible(true); + $slaProperty = new ReflectionProperty(OnTimeDelivery::class, 'slaMinutes'); + $slaProperty->setAccessible(true); + $groupProperty = new ReflectionProperty(RevenueTrend::class, 'groupBy'); + $groupProperty->setAccessible(true); + + $topDrivers = (new TopDrivers())->limit(500)->sortBy('unsupported'); + $onTime = (new OnTimeDelivery())->slaMinutes(-5); + $revenue = (new RevenueTrend())->groupBy('quarter'); + + expect($limitProperty->getValue($topDrivers))->toBe(50) + ->and($sortProperty->getValue($topDrivers))->toBe('orders_completed') + ->and($slaProperty->getValue($onTime))->toBe(0) + ->and($groupProperty->getValue($revenue))->toBe('day'); +}); + +test('operations pulse delta helper handles zero and non-zero previous values', function () { + $method = new ReflectionMethod(OperationsPulse::class, 'deltaPct'); + $method->setAccessible(true); + $pulse = new OperationsPulse(); + + expect($method->invoke($pulse, 5, 0))->toBe(100.0) + ->and($method->invoke($pulse, 0, 0))->toBeNull() + ->and($method->invoke($pulse, 15, 10))->toBe(50.0) + ->and($method->invoke($pulse, 5, 10))->toBe(-50.0); +}); diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php new file mode 100644 index 000000000..0346f19f9 --- /dev/null +++ b/server/tests/CommandContractsTest.php @@ -0,0 +1,180 @@ +lock; + } +} + +class FleetOpsCommandLockFake +{ + public bool $released = false; + + public function __construct(private bool $locked) + { + } + + public function get(): bool + { + return $this->locked; + } + + public function release(): void + { + $this->released = true; + } +} + +class FleetOpsTelematicProviderRegistryFake extends TelematicProviderRegistry +{ + public function __construct(Illuminate\Support\Collection $providers) + { + $this->providers = $providers; + } + + public function all(): Illuminate\Support\Collection + { + return $this->providers; + } +} + +function fleetOpsSyncTelematicsCommandWithOptions(array $options = []): SyncTelematics +{ + return new class($options) extends SyncTelematics { + public array $messages = []; + + public function __construct(private array $testOptions) + { + parent::__construct(); + } + + public function option($key = null) + { + return $key === null ? $this->testOptions : ($this->testOptions[$key] ?? null); + } + + public function info($string, $verbosity = null) + { + $this->messages[] = ['info', $string]; + } + + public function warn($string, $verbosity = null) + { + $this->messages[] = ['warn', $string]; + } + }; +} + +test('sync telematics exits cleanly when another process holds the lock', function () { + $lock = new FleetOpsCommandLockFake(false); + Cache::swap(new FleetOpsCommandCacheFake($lock)); + + $registry = new FleetOpsTelematicProviderRegistryFake(collect()); + $command = fleetOpsSyncTelematicsCommandWithOptions(['no-lock' => false]); + + expect($command->handle($registry))->toBe(Command::SUCCESS) + ->and($command->messages)->toContain(['warn', 'Another telematics sync run appears to be in progress.']) + ->and($lock->released)->toBeFalse(); +}); + +test('sync telematics reports no pollable providers after provider filtering', function () { + $registry = new FleetOpsTelematicProviderRegistryFake(collect([ + 'webhook' => new TelematicProviderDescriptor([ + 'key' => 'webhook', + 'label' => 'Webhook Provider', + 'supports_webhooks' => true, + 'supports_discovery' => true, + ]), + 'manual' => new TelematicProviderDescriptor([ + 'key' => 'manual', + 'label' => 'Manual Provider', + 'supports_discovery' => false, + ]), + ])); + + $command = fleetOpsSyncTelematicsCommandWithOptions([ + 'no-lock' => true, + 'provider' => [], + 'exclude-webhook-providers' => true, + ]); + + expect($command->handle($registry))->toBe(Command::SUCCESS) + ->and($command->messages)->toContain(['info', 'No pollable telematics providers found.']); +}); + +test('sync telematics filters requested pollable providers', function () { + $registry = new FleetOpsTelematicProviderRegistryFake(collect([ + 'afaqy' => new TelematicProviderDescriptor([ + 'key' => 'afaqy', + 'label' => 'Afaqy', + 'supports_discovery' => true, + ]), + 'samsara' => new TelematicProviderDescriptor([ + 'key' => 'samsara', + 'label' => 'Samsara', + 'supports_discovery' => true, + ]), + 'webhook_only' => new TelematicProviderDescriptor([ + 'key' => 'webhook_only', + 'label' => 'Webhook Only', + 'supports_webhooks' => true, + 'supports_discovery' => true, + ]), + ])); + + $command = fleetOpsSyncTelematicsCommandWithOptions([ + 'provider' => ['samsara', 'webhook_only'], + 'exclude-webhook-providers' => true, + ]); + + $method = new ReflectionMethod($command, 'pollableProviderKeys'); + $method->setAccessible(true); + + expect($method->invoke($command, $registry))->toBe(['samsara']); +}); + +test('test email command rejects unsupported email types before sending mail', function () { + $command = new class extends TestEmail { + public array $messages = []; + + public function argument($key = null) + { + $arguments = ['email' => 'customer@example.test']; + + return $key === null ? $arguments : ($arguments[$key] ?? null); + } + + public function option($key = null) + { + $options = ['type' => 'unknown']; + + return $key === null ? $options : ($options[$key] ?? null); + } + + public function info($string, $verbosity = null) + { + $this->messages[] = ['info', $string]; + } + + public function error($string, $verbosity = null) + { + $this->messages[] = ['error', $string]; + } + }; + + expect($command->handle())->toBe(Command::FAILURE) + ->and($command->messages)->toContain(['error', 'Unknown email type: unknown']); +}); diff --git a/server/tests/CoverageSummaryScriptTest.php b/server/tests/CoverageSummaryScriptTest.php new file mode 100644 index 000000000..262fd8b86 --- /dev/null +++ b/server/tests/CoverageSummaryScriptTest.php @@ -0,0 +1,47 @@ + + + + + + + + + +XML; + + file_put_contents($path, $xml); +} + +test('coverage summary reports line method class directory and file coverage', function () { + $fixture = tempnam(sys_get_temp_dir(), 'fleetops-clover-'); + writeCoverageFixture($fixture); + + exec(PHP_BINARY . ' scripts/coverage-summary.php ' . escapeshellarg($fixture), $output, $exitCode); + + @unlink($fixture); + + expect($exitCode)->toBe(0) + ->and(implode("\n", $output))->toContain('Line coverage: 50.00% (2/4 statements)') + ->and(implode("\n", $output))->toContain('Method coverage: 50.00% (1/2 methods)') + ->and(implode("\n", $output))->toContain('Class coverage: 0.00% (0/1 classes)') + ->and(implode("\n", $output))->toContain('Lowest covered directories:') + ->and(implode("\n", $output))->toContain('Lowest covered files:'); +}); + +test('coverage summary fails when coverage is below the configured threshold', function () { + $fixture = tempnam(sys_get_temp_dir(), 'fleetops-clover-'); + writeCoverageFixture($fixture, 3, 4); + + exec(PHP_BINARY . ' scripts/coverage-summary.php ' . escapeshellarg($fixture) . ' --fail-under=100 2>&1', $output, $exitCode); + + @unlink($fixture); + + expect($exitCode)->toBe(1) + ->and(implode("\n", $output))->toContain('Coverage 75.00% is below the required 100.00% line threshold.'); +}); + diff --git a/server/tests/EventContractsTest.php b/server/tests/EventContractsTest.php new file mode 100644 index 000000000..f3404b816 --- /dev/null +++ b/server/tests/EventContractsTest.php @@ -0,0 +1,120 @@ + $channel->name, $channels); +} + +test('driver location changed broadcasts driver telemetry payload', function () { + session([ + 'company' => 'company-1', + 'api_credential' => 'api-1', + ]); + + $driver = new Driver(); + $driver->setRawAttributes([ + 'uuid' => 'driver-uuid', + 'public_id' => 'driver_public', + 'internal_id' => 'driver_internal', + 'name' => 'Jane Driver', + 'phone' => '+15551234567', + 'location' => ['type' => 'Point', 'coordinates' => [103.8, 1.3]], + 'altitude' => 20, + 'heading' => 180, + 'speed' => 32, + ], true); + $driver->setRelation('user', (object) [ + 'name' => 'Jane Driver', + 'phone' => '+15551234567', + ]); + + $event = new DriverLocationChanged($driver, ['source' => 'telematics']); + + expect($event->broadcastAs())->toBe('driver.location_changed') + ->and(eventChannelNames($event->broadcastOn()))->toBe([ + 'company.company-1', + 'api.api-1', + 'driver.driver_public', + 'driver.driver-uuid', + ]) + ->and($event->broadcastWith())->toMatchArray([ + 'event' => 'driver.location_changed', + 'data' => [ + 'id' => 'driver_public', + 'internal_id' => 'driver_internal', + 'name' => 'Jane Driver', + 'phone' => '+15551234567', + 'location' => ['type' => 'Point', 'coordinates' => [103.8, 1.3]], + 'altitude' => 20, + 'heading' => 180, + 'speed' => 32, + 'additionalData' => ['source' => 'telematics'], + ], + ]) + ->and($event->eventId)->toStartWith('event_') + ->and($event->sentAt)->toBeString(); +}); + +test('vehicle location changed broadcasts vehicle telemetry payload', function () { + session([ + 'company' => 'company-1', + 'api_credential' => 'api-1', + ]); + + $vehicle = new Vehicle(); + $vehicle->setRawAttributes([ + 'uuid' => 'vehicle-uuid', + 'public_id' => 'vehicle_public', + 'plate_number' => 'ABC-123', + 'name' => 'Truck 12', + 'location' => ['type' => 'Point', 'coordinates' => [103.9, 1.4]], + 'altitude' => 22, + 'heading' => 90, + 'speed' => 45, + ], true); + + $event = new VehicleLocationChanged($vehicle, ['source' => 'device']); + + expect($event->broadcastAs())->toBe('vehicle.location_changed') + ->and(eventChannelNames($event->broadcastOn()))->toBe([ + 'company.company-1', + 'api.api-1', + 'vehicle.vehicle_public', + 'vehicle.vehicle-uuid', + ]) + ->and($event->broadcastWith())->toMatchArray([ + 'event' => 'vehicle.location_changed', + 'data' => [ + 'id' => 'vehicle_public', + 'plate_number' => 'ABC-123', + 'name' => 'Truck 12', + 'location' => ['type' => 'Point', 'coordinates' => [103.9, 1.4]], + 'altitude' => 22, + 'heading' => 90, + 'speed' => 45, + 'additionalData' => ['source' => 'device'], + ], + ]); +}); + +test('fuel provider events retain transaction and generated fuel report references', function () { + $transaction = new FuelProviderTransaction(); + $fuelReport = new FuelReport(); + + expect((new FuelProviderTransactionImported($transaction))->transaction)->toBe($transaction) + ->and((new FuelProviderTransactionMatched($transaction))->transaction)->toBe($transaction) + ->and((new FuelProviderTransactionUnmatched($transaction))->transaction)->toBe($transaction) + ->and((new FuelReportCreatedFromProvider($transaction, $fuelReport))->transaction)->toBe($transaction) + ->and((new FuelReportCreatedFromProvider($transaction, $fuelReport))->fuelReport)->toBe($fuelReport); +}); diff --git a/server/tests/ExceptionContractsTest.php b/server/tests/ExceptionContractsTest.php new file mode 100644 index 000000000..038622474 --- /dev/null +++ b/server/tests/ExceptionContractsTest.php @@ -0,0 +1,81 @@ + '/devices', 'retryable' => true], + 503, + $previous, + ); + + expect($exception->getMessage())->toBe('Provider failed') + ->and($exception->getCode())->toBe(503) + ->and($exception->getPrevious())->toBe($previous) + ->and($exception->context())->toBe(['endpoint' => '/devices', 'retryable' => true]); + }); + + test('rate limit exceptions use the telematic provider exception contract', function () { + $exception = new TelematicRateLimitExceededException('Rate limit exceeded', ['limit' => 60]); + + expect($exception)->toBeInstanceOf(TelematicProviderException::class) + ->and($exception->context())->toBe(['limit' => 60]); + }); + + test('user already exists exceptions retain the duplicate user context', function () { + $user = new User(); + $user->setRawAttributes([ + 'uuid' => 'user-1', + 'email' => 'customer@example.test', + ], true); + + $exception = new UserAlreadyExistsException('User exists', $user, 409); + + expect($exception->getMessage())->toBe('User exists') + ->and($exception->getCode())->toBe(409) + ->and($exception->getUser())->toBe($user); + }); + + test('customer conflict exceptions reuse duplicate user context', function () { + $user = new User(); + $exception = new CustomerUserConflictException('Customer user conflict', $user); + + expect($exception)->toBeInstanceOf(UserAlreadyExistsException::class) + ->and($exception->getUser())->toBe($user); + }); + + test('integrated vendor exceptions serialize the error response payload', function () { + $vendor = new IntegratedVendor(); + $vendor->setRawAttributes([ + 'uuid' => 'vendor-1', + ], true); + + $exception = new IntegratedVendorException('Vendor trigger failed', $vendor, 'quote'); + $response = $exception->toResponse(null); + + expect($exception->integratedVendor)->toBe($vendor) + ->and($exception->triggerMethod)->toBe('quote') + ->and($response->getStatusCode())->toBe(400) + ->and($response->getData(true))->toBe([ + 'errors' => ['Vendor trigger failed'], + 'integratedVendorId' => 'vendor-1', + ]); + }); +} diff --git a/server/tests/ExpansionContractsTest.php b/server/tests/ExpansionContractsTest.php new file mode 100644 index 000000000..ee0fc996a --- /dev/null +++ b/server/tests/ExpansionContractsTest.php @@ -0,0 +1,242 @@ +bindTo($target, $target::class); +} + +class FleetOpsExpansionRelationFake +{ + public array $calls = []; + + public function __construct(public string $name) + { + } + + public function without($relation) + { + $this->calls[] = ['without', $relation]; + + return $this; + } + + public function where($column, $operator = null, $value = null) + { + $this->calls[] = ['where', $column, $operator, $value]; + + return $this; + } +} + +class FleetOpsExpansionModelFake +{ + public array $calls = []; + public array $relations = []; + + public function __construct() + { + foreach (['driver', 'driverProfiles', 'customer', 'contact'] as $name) { + $this->relations[$name] = new FleetOpsExpansionRelationFake($name); + } + } + + public function hasMany($class) + { + $this->calls[] = ['hasMany', $class]; + + return $this->relations['driverProfiles']; + } + + public function hasOne($class, $foreignKey = null, $localKey = null) + { + $this->calls[] = ['hasOne', $class, $foreignKey, $localKey]; + + if ($class === Driver::class) { + return $this->relations['driver']; + } + + return count(array_filter($this->calls, fn ($call) => $call[0] === 'hasOne' && $call[1] === Contact::class)) === 1 + ? $this->relations['customer'] + : $this->relations['contact']; + } + + public function driver() + { + return $this->relations['driver']; + } +} + +class FleetOpsFilterQueryFake +{ + public array $calls = []; + + public function where($column, $operator = null, $value = null) + { + $this->calls[] = ['where', $column, $operator, $value]; + + return $this; + } + + public function whereNull($column) + { + $this->calls[] = ['whereNull', $column]; + + return $this; + } + + public function orWhere($column, $operator = null, $value = null) + { + $this->calls[] = ['orWhere', $column, $operator, $value]; + + return $this; + } + + public function orwhereHas($relation, Closure $callback) + { + $nested = new self(); + $callback($nested); + + $this->calls[] = ['orwhereHas', $relation, $nested->calls]; + + return $this; + } +} + +class FleetOpsFilterBuilderFake +{ + public array $calls = []; + + public function where($column, $operator = null, $value = null) + { + if ($column instanceof Closure) { + $query = new FleetOpsFilterQueryFake(); + $column($query); + $this->calls[] = ['whereNested', $query->calls]; + + return $this; + } + + $this->calls[] = ['where', $column, $operator, $value]; + + return $this; + } + + public function whereIn($column, array $values) + { + $this->calls[] = ['whereIn', $column, $values]; + + return $this; + } + + public function whereDoesntHave($relation, Closure $callback) + { + $query = new FleetOpsFilterQueryFake(); + $callback($query); + $this->calls[] = ['whereDoesntHave', $relation, $query->calls]; + + return $this; + } +} + +test('company expansion exposes driver relationship contract', function () { + $company = new FleetOpsExpansionModelFake(); + $drivers = bindFleetOpsExpansionClosure(CompanyExpansion::drivers(), $company); + + expect(CompanyExpansion::target())->toBe(\Fleetbase\Models\Company::class) + ->and($drivers())->toBe($company->relations['driverProfiles']) + ->and($company->calls)->toBe([['hasMany', Driver::class]]); +}); + +test('user expansion exposes driver and customer relationship contracts', function () { + $user = new FleetOpsExpansionModelFake(); + + $driver = bindFleetOpsExpansionClosure(UserExpansion::driver(), $user); + $driverProfiles = bindFleetOpsExpansionClosure(UserExpansion::driverProfiles(), $user); + $customer = bindFleetOpsExpansionClosure(UserExpansion::customer(), $user); + $contact = bindFleetOpsExpansionClosure(UserExpansion::contact(), $user); + + expect(UserExpansion::target())->toBe(\Fleetbase\Models\User::class) + ->and($driver())->toBe($user->relations['driver']) + ->and($driverProfiles())->toBe($user->relations['driverProfiles']) + ->and($customer())->toBe($user->relations['customer']) + ->and($contact())->toBe($user->relations['contact']) + ->and($user->relations['driver']->calls)->toBe([['without', 'user']]) + ->and($user->relations['customer']->calls)->toBe([ + ['where', 'type', 'customer', null], + ['without', 'user'], + ]) + ->and($user->relations['contact']->calls)->toBe([['without', 'user']]); +}); + +test('user expansion filters current driver session to the active company', function () { + session(['company' => 'company-uuid']); + + $user = new FleetOpsExpansionModelFake(); + $currentDriverSession = bindFleetOpsExpansionClosure(UserExpansion::currentDriverSession(), $user); + + expect($currentDriverSession())->toBe($user->relations['driver']) + ->and($user->relations['driver']->calls)->toBe([['where', 'company_uuid', 'company-uuid', null]]); +}); + +test('user filter expansion applies simple user type filters', function () { + $builder = new FleetOpsFilterBuilderFake(); + $filter = new class($builder) { + public function __construct(public $builder) + { + } + }; + + bindFleetOpsExpansionClosure(UserFilterExpansion::isCustomer(), $filter)(); + bindFleetOpsExpansionClosure(UserFilterExpansion::canBeDriver(), $filter)(); + + expect(UserFilterExpansion::target())->toBe(\Fleetbase\Http\Filter\UserFilter::class) + ->and($builder->calls)->toBe([ + ['where', 'type', 'customer', null], + ['whereIn', 'type', ['user', 'admin']], + ]); +}); + +test('user filter expansion applies driver and customer relationship filters', function () { + session(['company' => 'company-uuid']); + + $builder = new FleetOpsFilterBuilderFake(); + $filter = new class($builder) { + public function __construct(public $builder) + { + } + }; + + bindFleetOpsExpansionClosure(UserFilterExpansion::isDriver(), $filter)(); + bindFleetOpsExpansionClosure(UserFilterExpansion::isNotCustomer(), $filter)(); + bindFleetOpsExpansionClosure(UserFilterExpansion::doesntHaveDriver(), $filter)(); + bindFleetOpsExpansionClosure(UserFilterExpansion::doesntHaveContact(), $filter)(); + bindFleetOpsExpansionClosure(UserFilterExpansion::doesntHaveCustomer(), $filter)(); + + expect($builder->calls)->toBe([ + ['whereNested', [ + ['where', 'type', 'driver', null], + ['orwhereHas', 'driverProfiles', [ + ['where', 'company_uuid', 'company-uuid', null], + ]], + ]], + ['whereNested', [ + ['whereNull', 'type'], + ['orWhere', 'type', '!=', 'customer'], + ]], + ['whereDoesntHave', 'driverProfiles', [ + ['where', 'company_uuid', 'company-uuid', null], + ]], + ['whereDoesntHave', 'contact', [ + ['where', 'company_uuid', 'company-uuid', null], + ]], + ['whereDoesntHave', 'customer', [ + ['where', 'company_uuid', 'company-uuid', null], + ]], + ]); +}); diff --git a/server/tests/ExportCoverageTest.php b/server/tests/ExportCoverageTest.php index defc6c6f6..0df1e73d8 100644 --- a/server/tests/ExportCoverageTest.php +++ b/server/tests/ExportCoverageTest.php @@ -1,9 +1,23 @@ match(['get', 'post'], 'export', \$controller('export'));"))->toBeGreaterThanOrEqual(19); }); + +test('all export classes expose stable spreadsheet headings and column formats', function () { + $exports = [ + ContactExport::class, + DeviceExport::class, + DriverExport::class, + EquipmentExport::class, + FleetExport::class, + FuelReportExport::class, + IssueExport::class, + MaintenanceExport::class, + MaintenanceScheduleExport::class, + OrderExport::class, + PartExport::class, + PlaceExport::class, + SensorExport::class, + ServiceAreaExport::class, + ServiceRateExport::class, + TelematicExport::class, + VehicleExport::class, + VendorExport::class, + WorkOrderExport::class, + ]; + + foreach ($exports as $exportClass) { + $export = new $exportClass(['selected-resource']); + $heading = $export->headings(); + $formats = $export->columnFormats(); + + expect($heading) + ->not->toBeEmpty() + ->each->toBeString() + ->and($heading)->toContain('ID') + ->and($formats)->toBeArray(); + + foreach (array_keys($formats) as $column) { + expect($column)->toMatch('/^[A-Z]+$/'); + } + } +}); diff --git a/server/tests/FlowResourceTest.php b/server/tests/FlowResourceTest.php new file mode 100644 index 000000000..d08ee1ed9 --- /dev/null +++ b/server/tests/FlowResourceTest.php @@ -0,0 +1,201 @@ +dynamicValues[$value] ?? $value; + } + + public function hasCompletedActivity(Activity $activity): bool + { + return in_array($activity->code, $this->completedActivityCodes, true); + } +} + +function flowTestOrder(array $dynamicValues = [], array $completedActivityCodes = []): FlowTestOrder +{ + $order = new FlowTestOrder(); + $order->dynamicValues = $dynamicValues; + $order->completedActivityCodes = $completedActivityCodes; + + return $order; +} + +function flowFixture(): array +{ + return [ + 'activities' => [ + [ + 'code' => 'created', + 'activities' => ['ready'], + ], + [ + 'code' => 'ready', + 'complete' => false, + 'events' => ['order_ready'], + 'activities' => ['completed'], + 'logic' => [ + [ + 'type' => 'and', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ], + ], + ], + ], + [ + 'code' => 'completed', + 'complete' => true, + 'activities' => [], + 'logic' => [ + [ + 'type' => 'if', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'completed'], + ], + ], + ], + ], + ], + 'created' => [ + 'code' => 'created', + 'activities' => ['ready'], + ], + 'ready' => [ + 'code' => 'ready', + 'complete' => false, + 'events' => ['order_ready'], + 'activities' => ['completed'], + 'logic' => [ + [ + 'type' => 'and', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ], + ], + ], + ], + 'completed' => [ + 'code' => 'completed', + 'complete' => true, + 'activities' => [], + 'logic' => [ + [ + 'type' => 'if', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'completed'], + ], + ], + ], + ], + ]; +} + +test('flow resources expose mutable attributes and json serialization', function () { + $resource = new FlowResource(['code' => 'created']); + $returned = $resource->set('status', 'Created'); + + expect($returned)->toBe($resource) + ->and($resource->code)->toBe('created') + ->and(isset($resource->status))->toBeTrue() + ->and($resource->get('missing', 'fallback'))->toBe('fallback') + ->and($resource->serialize())->toBe(['code' => 'created', 'status' => 'Created']) + ->and($resource->toArray())->toBe($resource->serialize()) + ->and(json_decode($resource->toJson(), true))->toBe($resource->serialize()) + ->and($resource->jsonSerialize())->toBe($resource->serialize()); +}); + +test('flow locates keyed activities and iterates configured activity list', function () { + $flow = new Flow(flowFixture()); + + expect(iterator_to_array($flow))->toHaveCount(3) + ->and($flow->getActivity('ready'))->toBeInstanceOf(Activity::class) + ->and($flow->getActivity('ready')?->code)->toBe('ready') + ->and($flow->getActivity('missing'))->toBeNull() + ->and(Flow::isActivity($flow->getActivity('ready')))->toBeTrue() + ->and(Flow::isActivity(new stdClass()))->toBeFalse(); +}); + +test('flow conditions evaluate comparison operators and reject unknown operators', function () { + $order = flowTestOrder([ + 'status' => 'Ready', + 'priority' => 3, + 'notes' => 'deliver fragile parcel', + ]); + + expect((new Condition(['field' => 'status', 'operator' => 'equal', 'value' => 'ready']))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'status', 'operator' => 'notEqual', 'value' => 'completed']))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'priority', 'operator' => 'greaterThan', 'value' => 2]))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'priority', 'operator' => 'lessThanOrEqual', 'value' => 3]))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'notes', 'operator' => 'contains', 'value' => 'deliver fragile parcel today']))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'notes', 'operator' => 'beginsWith', 'value' => 'deliver fragile parcel']))->eval($order))->toBeTrue() + ->and((new Condition(['field' => 'notes', 'operator' => 'endsWith', 'value' => 'today deliver fragile parcel']))->eval($order))->toBeTrue(); + + expect(fn () => (new Condition(['field' => 'status', 'operator' => 'between', 'value' => []]))->eval($order)) + ->toThrow(Exception::class, 'Unknown operator: between'); +}); + +test('flow logic combines conditions with and or not and if semantics', function () { + $readyOrder = flowTestOrder(['status' => 'ready', 'priority' => 5]); + $heldOrder = flowTestOrder(['status' => 'held', 'priority' => 1]); + + expect((new Logic([ + 'type' => 'and', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ['field' => 'priority', 'operator' => 'greaterThanOrEqual', 'value' => 5], + ], + ]))->passes($readyOrder))->toBeTrue() + ->and((new Logic([ + 'type' => 'or', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ['field' => 'priority', 'operator' => 'greaterThan', 'value' => 5], + ], + ]))->passes($readyOrder))->toBeTrue() + ->and((new Logic([ + 'type' => 'not', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ], + ]))->passes($heldOrder))->toBeTrue() + ->and((new Logic([ + 'type' => 'if', + 'conditions' => [ + ['field' => 'status', 'operator' => 'equal', 'value' => 'ready'], + ], + ]))->passes($readyOrder))->toBeTrue(); + + expect(fn () => (new Logic(['type' => 'xor']))->passes($readyOrder)) + ->toThrow(Exception::class, 'Unknown logic type: xor'); +}); + +test('activities expose events child traversal next previous and completion state', function () { + $flow = flowFixture(); + $activity = new Activity($flow['ready'], $flow); + + expect($activity->logic)->toHaveCount(1) + ->and($activity->events[0])->toBeInstanceOf(Event::class) + ->and($activity->children())->toHaveCount(1) + ->and($activity->hasChildActivity('completed'))->toBeTrue() + ->and($activity->hasChildActivity('missing'))->toBeFalse() + ->and($activity->is('ready'))->toBeTrue() + ->and($activity->complete())->toBeFalse() + ->and($activity->completesOrder())->toBeFalse() + ->and($activity->isCompleted(flowTestOrder([], ['ready'])))->toBeTrue() + ->and($activity->getNext(flowTestOrder(['status' => 'completed']))->first()?->code)->toBe('completed') + ->and($activity->getNext(flowTestOrder(['status' => 'ready'])))->toHaveCount(0) + ->and($activity->getPrevious()->first()?->code)->toBe('created'); +}); diff --git a/server/tests/FuelProviderImplementationTest.php b/server/tests/FuelProviderImplementationTest.php new file mode 100644 index 000000000..b1ff46692 --- /dev/null +++ b/server/tests/FuelProviderImplementationTest.php @@ -0,0 +1,193 @@ +authenticate($connection); + } + + public function listTransactions(FuelProviderConnection $connection, Carbon $from, Carbon $to, array $options = []): Collection + { + return collect(); + } + + public function exposedBaseUrl(FuelProviderConnection $connection): string + { + return $this->baseUrl($connection); + } + + public function exposedHash(array $payload): string + { + return $this->transactionHash($payload); + } + + public function exposedMinorCurrencyUnit($amount): ?int + { + return $this->minorCurrencyUnit($amount); + } + + public function exposedDateFrom($value): ?Carbon + { + return $this->dateFrom($value); + } + + public function exposedCompactIdentifier($value): ?string + { + return $this->compactIdentifier($value); + } +} + +class PetroAppFuelProviderHarness extends PetroAppFuelProvider +{ + public function exposedBaseUrl(FuelProviderConnection $connection): string + { + return $this->baseUrl($connection); + } + + public function exposedHeaders(FuelProviderConnection $connection): array + { + return $this->headers($connection); + } + + public function exposedNormalizeBill(array $bill): array + { + return $this->normalizeBill($bill); + } +} + +function fuelProviderConnection(array $credentials = []): FuelProviderConnection +{ + $connection = new FuelProviderConnection(); + $connection->credentials = $credentials; + + return $connection; +} + +test('abstract fuel provider exposes safe defaults and helper normalization', function () { + $provider = new FuelProviderHarness(); + $connection = fuelProviderConnection(['base_url' => 'https://fuel.example.test/api/']); + + expect($provider->authenticate($connection))->toBe([ + 'success' => true, + 'message' => 'Authentication headers prepared.', + ]) + ->and($provider->listVehicles($connection))->toBeInstanceOf(Collection::class) + ->and($provider->listVehicles($connection)->all())->toBe([]) + ->and($provider->listStations($connection)->all())->toBe([]) + ->and($provider->pushTrip($connection, ['id' => 'trip-1']))->toBe([ + 'success' => false, + 'message' => 'Provider does not support trip push.', + ]) + ->and($provider->syncVehicle($connection, ['id' => 'vehicle-1']))->toBe([ + 'success' => false, + 'message' => 'Provider does not support vehicle sync.', + ]) + ->and($provider->webhookPayloadToTransaction($connection, ['event' => 'fuel']))->toBeNull() + ->and($provider->exposedBaseUrl($connection))->toBe('https://fuel.example.test/api') + ->and($provider->exposedHash(['b' => 2, 'a' => 1]))->toBe(hash('sha256', json_encode(['b' => 2, 'a' => 1]))) + ->and($provider->exposedMinorCurrencyUnit(null))->toBeNull() + ->and($provider->exposedMinorCurrencyUnit(''))->toBeNull() + ->and($provider->exposedMinorCurrencyUnit('12.345'))->toBe(1235) + ->and($provider->exposedDateFrom(null))->toBeNull() + ->and($provider->exposedDateFrom('2026-07-01')->toDateString())->toBe('2026-07-01') + ->and($provider->exposedCompactIdentifier(' CARD 123 '))->toBe('CARD 123') + ->and($provider->exposedCompactIdentifier(' '))->toBeNull(); +}); + +test('petroapp provider resolves base urls auth headers and normalized bills', function () { + $provider = new PetroAppFuelProviderHarness(); + + $defaultConnection = fuelProviderConnection(['api_token' => 'token-1']); + $customConnection = fuelProviderConnection([ + 'base_url' => 'https://petroapp.example.test/root/', + 'auth_type' => 'ws_sk_header', + 'api_key' => 'key-1', + 'version' => 'v3.1', + ]); + + $normalized = $provider->exposedNormalizeBill([ + 'id' => 321, + 'bill_date' => '2026-07-10', + 'vehicle_id' => ' vehicle-9 ', + 'vehicle_card_id' => ' card-9 ', + 'internal_number' => ' internal-9 ', + 'structure_number' => ' structure-9 ', + 'plate_snum' => ' ABC 123 ', + 'vin' => ' VIN9 ', + 'serial_number' => ' SER9 ', + 'call_sign' => ' CALL9 ', + 'trip_number' => ' TRIP9 ', + 'station_name' => ' Station 1 ', + 'station_lat' => 24.7, + 'station_lng' => 46.7, + 'num_of_liters' => 55.5, + 'cost' => '120.25', + 'odometer' => 123456, + 'payment_method' => 'card', + 'payment_method_text' => 'Fleet card', + 'branch_name' => 'Riyadh', + 'city' => 'Riyadh', + 'district' => 'North', + 'delegate_name' => 'Operator', + ]); + + expect($provider->key())->toBe('petroapp') + ->and($provider->name())->toBe('PetroApp') + ->and($provider->exposedBaseUrl($defaultConnection))->toBe('https://app-public.staging.petroapp.app/webservice') + ->and($provider->exposedBaseUrl($customConnection))->toBe('https://petroapp.example.test/root') + ->and($provider->exposedHeaders($defaultConnection))->toBe([ + 'WS-Version' => 'v2.0', + 'Authorization' => 'Bearer token-1', + ]) + ->and($provider->exposedHeaders($customConnection))->toBe([ + 'WS-Version' => 'v3.1', + 'WS-SK' => 'key-1', + ]) + ->and($normalized)->toMatchArray([ + 'provider' => 'petroapp', + 'provider_transaction_id' => '321', + 'provider_vehicle_id' => 'vehicle-9', + 'vehicle_card_id' => 'card-9', + 'internal_number' => 'internal-9', + 'structure_number' => 'structure-9', + 'plate_number' => 'ABC 123', + 'vin' => 'VIN9', + 'serial_number' => 'SER9', + 'call_sign' => 'CALL9', + 'trip_number' => 'TRIP9', + 'station_name' => 'Station 1', + 'station_latitude' => 24.7, + 'station_longitude' => 46.7, + 'volume' => 55.5, + 'metric_unit' => 'l', + 'amount' => 12025, + 'currency' => 'SAR', + 'odometer' => 123456, + ]) + ->and($normalized['transaction_at']->toDateString())->toBe('2026-07-10') + ->and($normalized['normalized_payload'])->toMatchArray([ + 'payment_method' => 'card', + 'payment_method_text' => 'Fleet card', + 'branch_name' => 'Riyadh', + 'city' => 'Riyadh', + 'district' => 'North', + 'delegate_name' => 'Operator', + ]); +}); diff --git a/server/tests/FuelProviderResourceContractsTest.php b/server/tests/FuelProviderResourceContractsTest.php new file mode 100644 index 000000000..9740de4f2 --- /dev/null +++ b/server/tests/FuelProviderResourceContractsTest.php @@ -0,0 +1,168 @@ +uri; + } +} + +function fleetopsInternalResourceRequest(): Request +{ + $request = Request::create('/int/v1/fleet-ops/resource-test', 'GET'); + $request->setRouteResolver(fn () => new FleetOpsResourceRouteFixture('int/v1/fleet-ops/resource-test')); + app()->instance('request', $request); + + return $request; +} + +function fuelProviderResourceFixture(array $attributes): object +{ + return (object) $attributes; +} + +test('fuel provider connection resource exposes public identifiers and sync state', function () { + $connection = fuelProviderResourceFixture([ + 'id' => 12, + 'uuid' => 'connection-uuid', + 'public_id' => 'fuel_connection_public', + 'provider' => 'test_provider', + 'name' => 'Main Fuel Account', + 'environment' => 'production', + 'status' => 'configured', + 'sync_settings' => ['window_days' => 7], + 'last_sync_state' => ['cursor' => 'abc'], + 'last_synced_at' => '2026-07-01 10:00:00', + 'last_tested_at' => '2026-07-01 09:00:00', + 'last_error' => null, + 'meta' => ['team' => 'ops'], + 'updated_at' => '2026-07-01 11:00:00', + 'created_at' => '2026-07-01 08:00:00', + ]); + + $payload = (new FuelProviderConnectionResource($connection))->toArray(fleetopsInternalResourceRequest()); + + expect($payload)->toMatchArray([ + 'id' => 'fuel_connection_public', + 'provider' => 'test_provider', + 'name' => 'Main Fuel Account', + 'environment' => 'production', + 'status' => 'configured', + 'sync_settings' => ['window_days' => 7], + 'last_sync_state' => ['cursor' => 'abc'], + 'meta' => ['team' => 'ops'], + ]); +}); + +test('fuel provider sync run resource exposes counters windows and totals', function () { + $syncRun = fuelProviderResourceFixture([ + 'id' => 55, + 'uuid' => 'sync-run-uuid', + 'public_id' => 'sync_run_public', + 'fuel_provider_connection_uuid' => 'connection-uuid', + 'provider' => 'test_provider', + 'status' => 'finished', + 'from' => '2026-06-01', + 'to' => '2026-06-30', + 'imported' => 10, + 'matched' => 7, + 'unmatched' => 3, + 'fuel_reports_created' => 5, + 'liters' => 432.5, + 'amount' => 1200, + 'started_at' => '2026-07-01 09:00:00', + 'finished_at' => '2026-07-01 09:05:00', + 'error' => null, + 'summary' => ['warnings' => 1], + 'meta' => ['source' => 'manual'], + 'updated_at' => '2026-07-01 09:06:00', + 'created_at' => '2026-07-01 08:55:00', + ]); + + $payload = (new FuelProviderSyncRunResource($syncRun))->toArray(fleetopsInternalResourceRequest()); + + expect($payload)->toMatchArray([ + 'id' => 'sync_run_public', + 'provider' => 'test_provider', + 'status' => 'finished', + 'imported' => 10, + 'matched' => 7, + 'unmatched' => 3, + 'fuel_reports_created' => 5, + 'liters' => 432.5, + 'amount' => 1200, + 'summary' => ['warnings' => 1], + 'meta' => ['source' => 'manual'], + ]); +}); + +test('fuel provider transaction resource exposes identifiers matching fields and raw payloads', function () { + $transaction = fuelProviderResourceFixture([ + 'id' => 99, + 'uuid' => 'transaction-uuid', + 'public_id' => 'fuel_transaction_public', + 'fuel_provider_connection_uuid' => 'connection-uuid', + 'fuel_report_uuid' => 'fuel-report-uuid', + 'fuel_report_id' => 'fuel_report_public', + 'vehicle_uuid' => 'vehicle-uuid', + 'driver_uuid' => 'driver-uuid', + 'order_uuid' => 'order-uuid', + 'provider' => 'test_provider', + 'provider_transaction_id' => 'txn-123', + 'provider_vehicle_id' => 'provider-vehicle-1', + 'vehicle_card_id' => 'card-1', + 'internal_number' => 'internal-1', + 'structure_number' => 'structure-1', + 'plate_number' => 'ABC-123', + 'vin' => 'VIN123', + 'serial_number' => 'SER123', + 'call_sign' => 'CALL-1', + 'trip_number' => 'TRIP-1', + 'station_name' => 'Station A', + 'station_latitude' => 1.25, + 'station_longitude' => 103.75, + 'station_location' => ['type' => 'Point', 'coordinates' => [103.75, 1.25]], + 'transaction_at' => '2026-07-01 12:00:00', + 'volume' => 80, + 'metric_unit' => 'L', + 'amount' => 240, + 'currency' => 'USD', + 'odometer' => 12345, + 'sync_status' => 'matched', + 'matched_at' => '2026-07-01 12:05:00', + 'vehicle_name' => 'Truck 12', + 'driver_name' => 'Jane Driver', + 'normalized_payload' => ['amount' => 240], + 'raw_payload' => ['source' => 'provider'], + 'meta' => ['review_status' => 'approved'], + 'updated_at' => '2026-07-01 12:06:00', + 'created_at' => '2026-07-01 12:01:00', + ]); + + $payload = (new FuelProviderTransactionResource($transaction))->toArray(fleetopsInternalResourceRequest()); + + expect($payload)->toMatchArray([ + 'id' => 'fuel_transaction_public', + 'provider' => 'test_provider', + 'provider_transaction_id' => 'txn-123', + 'plate_number' => 'ABC-123', + 'station_name' => 'Station A', + 'station_location' => ['type' => 'Point', 'coordinates' => [103.75, 1.25]], + 'volume' => 80, + 'metric_unit' => 'L', + 'amount' => 240, + 'currency' => 'USD', + 'odometer' => 12345, + 'sync_status' => 'matched', + ]); +}); diff --git a/server/tests/HOSConstraintContractsTest.php b/server/tests/HOSConstraintContractsTest.php new file mode 100644 index 000000000..7275bfa64 --- /dev/null +++ b/server/tests/HOSConstraintContractsTest.php @@ -0,0 +1,128 @@ +setAccessible(true); + + return $reflection->invokeArgs($constraint, $arguments); +} + +function fleetOpsScheduleItem(array $attributes): ScheduleItem +{ + $item = new FleetOpsHosScheduleItemFake(); + + foreach ($attributes as $key => $value) { + $item->{$key} = $value; + } + + return $item; +} + +function fleetOpsRecentScheduleItem(array $attributes): object +{ + return (object) $attributes; +} + +test('hos constraint calculates driving hours across current and recent schedule items', function () { + $constraint = new HOSConstraint(); + $current = fleetOpsScheduleItem([ + 'duration' => 120, + 'start_at' => '2026-07-19 12:00:00', + ]); + $recent = collect([ + fleetOpsRecentScheduleItem(['duration' => 90, 'start_at' => '2026-07-19 08:00:00']), + fleetOpsRecentScheduleItem(['duration' => 150, 'start_at' => '2026-07-19 10:00:00']), + ]); + + expect(invokeFleetOpsHosConstraint($constraint, 'calculateTotalDrivingHours', [$current, $recent])) + ->toBe(6.0) + ->and(invokeFleetOpsHosConstraint($constraint, 'calculateDrivingHoursSince', [ + $current, + $recent, + Carbon::parse('2026-07-19 09:00:00'), + ]))->toBe(4.5); +}); + +test('hos constraint enforces driving and weekly hour limits', function () { + $constraint = new HOSConstraint(); + $current = fleetOpsScheduleItem([ + 'duration' => 120, + 'start_at' => '2026-07-19 12:00:00', + ]); + + $withinLimit = collect([ + fleetOpsRecentScheduleItem(['duration' => 300, 'start_at' => '2026-07-18 08:00:00']), + fleetOpsRecentScheduleItem(['duration' => 180, 'start_at' => '2026-07-17 08:00:00']), + ]); + + $overLimit = collect([ + fleetOpsRecentScheduleItem(['duration' => 600, 'start_at' => '2026-07-18 08:00:00']), + fleetOpsRecentScheduleItem(['duration' => 120, 'start_at' => '2026-07-17 08:00:00']), + ]); + + $weeklyOverLimit = collect([ + fleetOpsRecentScheduleItem(['duration' => 4200, 'start_at' => '2026-07-18 08:00:00']), + fleetOpsRecentScheduleItem(['duration' => 120, 'start_at' => '2026-07-01 08:00:00']), + ]); + + expect(invokeFleetOpsHosConstraint($constraint, 'check11HourDrivingLimit', [$current, $withinLimit]))->toBeTrue() + ->and(invokeFleetOpsHosConstraint($constraint, 'check11HourDrivingLimit', [$current, $overLimit]))->toBeFalse() + ->and(invokeFleetOpsHosConstraint($constraint, 'check60_70HourWeeklyLimit', [$current, $withinLimit]))->toBeTrue() + ->and(invokeFleetOpsHosConstraint($constraint, 'check60_70HourWeeklyLimit', [$current, $weeklyOverLimit]))->toBeFalse(); +}); + +test('hos constraint handles duty windows and thirty minute break requirements', function () { + $constraint = new HOSConstraint(); + $current = fleetOpsScheduleItem([ + 'duration' => 120, + 'start_at' => '2026-07-19 12:00:00', + 'end_at' => '2026-07-19 14:00:00', + 'break_start_at' => null, + 'break_end_at' => null, + ]); + + $recentWithBreak = collect([ + fleetOpsRecentScheduleItem([ + 'duration' => 300, + 'start_at' => '2026-07-19 05:00:00', + 'break_start_at' => '2026-07-19 08:00:00', + 'break_end_at' => '2026-07-19 08:30:00', + ]), + ]); + + $recentWithoutBreak = collect([ + fleetOpsRecentScheduleItem([ + 'duration' => 420, + 'start_at' => '2026-07-19 05:00:00', + 'break_start_at' => null, + 'break_end_at' => null, + ]), + ]); + + $currentWithBreak = fleetOpsScheduleItem([ + 'duration' => 120, + 'start_at' => '2026-07-19 12:00:00', + 'end_at' => '2026-07-19 14:00:00', + 'break_start_at' => '2026-07-19 12:30:00', + 'break_end_at' => '2026-07-19 13:00:00', + ]); + + expect(invokeFleetOpsHosConstraint($constraint, 'check14HourDutyWindow', [$current, collect()]))->toBeTrue() + ->and(invokeFleetOpsHosConstraint($constraint, 'check30MinuteBreak', [$current, $recentWithBreak]))->toBeTrue() + ->and(invokeFleetOpsHosConstraint($constraint, 'check30MinuteBreak', [$current, $recentWithoutBreak]))->toBeFalse() + ->and(invokeFleetOpsHosConstraint($constraint, 'check30MinuteBreak', [$currentWithBreak, $recentWithoutBreak]))->toBeTrue(); +}); diff --git a/server/tests/ImportContractsTest.php b/server/tests/ImportContractsTest.php new file mode 100644 index 000000000..f1c5759f9 --- /dev/null +++ b/server/tests/ImportContractsTest.php @@ -0,0 +1,51 @@ + 'row-1'], + ['id' => 'row-2'], + ]); + + expect((new OrdersImport())->collection($rows))->toBe($rows) + ->and((new VehicleRowsImport())->collection($rows))->toBe($rows); +}); + +test('blank-row guarded imports skip empty spreadsheet rows before model creation', function () { + $guardedImports = [ + 'EquipmentImport.php', + 'MaintenanceImport.php', + 'MaintenanceScheduleImport.php', + 'PartImport.php', + 'WorkOrderImport.php', + ]; + + foreach ($guardedImports as $file) { + $source = file_get_contents(dirname(__DIR__) . '/src/Imports/' . $file); + + expect($source) + ->toContain('if (empty($row))') + ->toContain('continue;') + ->toContain('$this->imported++'); + } +}); + +test('all model-backed import adapters maintain imported row counters', function () { + $importsPath = dirname(__DIR__) . '/src/Imports'; + + foreach (glob($importsPath . '/*Import.php') as $path) { + if (basename($path) === 'OrdersImport.php') { + continue; + } + + $source = file_get_contents($path); + + expect($source) + ->toContain('public int $imported = 0;') + ->toContain('$this->imported++') + ->toContain('::createFromImport($row, true);'); + } +}); diff --git a/server/tests/MetricsRegistryTest.php b/server/tests/MetricsRegistryTest.php index ad05e178c..abdf1adee 100644 --- a/server/tests/MetricsRegistryTest.php +++ b/server/tests/MetricsRegistryTest.php @@ -4,7 +4,52 @@ use Fleetbase\FleetOps\Support\Metrics\OrdersInProgressMetric; use Fleetbase\FleetOps\Support\Metrics\Registry; use Fleetbase\FleetOps\Support\Metrics\TotalTimeTraveledMetric; +use Fleetbase\Models\Company; use Fleetbase\Models\Transaction; +use Illuminate\Support\Carbon; + +class TestFleetOpsMetric extends AbstractMetric +{ + public array $ranges = []; + + public static function slug(): string + { + return 'test_metric'; + } + + public function format(): string + { + return 'currency'; + } + + public function currency(): ?string + { + return 'USD'; + } + + protected function query(?\DateTimeInterface $start, ?\DateTimeInterface $end) + { + $this->ranges[] = [ + 'start' => $start?->format('Y-m-d'), + 'end' => $end?->format('Y-m-d'), + ]; + + return ['start' => $start, 'end' => $end]; + } + + protected function aggregate($query): float|int + { + if (!$query['start'] || !$query['end']) { + return 10; + } + + return match ($query['start']->format('Y-m-d')) { + '2026-01-01' => 20, + '2025-12-01' => 10, + default => (int) $query['start']->format('d'), + }; + } +} test('registry exposes every known metric slug', function () { $slugs = Registry::slugs(); @@ -122,3 +167,59 @@ expect($statuses)->toContain('dispatched'); expect($statuses)->toContain('started'); }); + +test('abstract metric builds value delta currency and sparkline payloads', function () { + $company = new Company(); + $company->setRawAttributes(['uuid' => 'company-1'], true); + + $metric = TestFleetOpsMetric::forCompany($company) + ->between(Carbon::parse('2026-01-01'), Carbon::parse('2026-02-01')) + ->compareTo(Carbon::parse('2025-12-01'), Carbon::parse('2026-01-01')) + ->withSparkline(2, 'day'); + + expect($metric->value())->toBe(20) + ->and($metric->delta())->toBe(100.0) + ->and($metric->sparkline())->toBe([ + 'labels' => ['2026-01-30', '2026-01-31'], + 'data' => [30, 31], + ]) + ->and($metric->get())->toMatchArray([ + 'slug' => 'test_metric', + 'value' => 20, + 'format' => 'currency', + 'currency' => 'USD', + 'delta_pct' => 100.0, + 'sparkline' => [ + 'labels' => ['2026-01-30', '2026-01-31'], + 'data' => [30, 31], + ], + ]); +}); + +test('abstract metric handles missing comparison and zero previous periods', function () { + expect((new TestFleetOpsMetric())->delta())->toBeNull() + ->and((new TestFleetOpsMetric())->withSparkline(0)->sparkline())->toBeNull(); + + $zeroPrevious = new class extends TestFleetOpsMetric { + protected function aggregate($query): float|int + { + return $query['start']?->format('Y-m-d') === '2025-12-01' ? 0 : 5; + } + }; + + $flatPrevious = new class extends TestFleetOpsMetric { + protected function aggregate($query): float|int + { + return 0; + } + }; + + expect($zeroPrevious + ->between(Carbon::parse('2026-01-01'), Carbon::parse('2026-02-01')) + ->compareTo(Carbon::parse('2025-12-01'), Carbon::parse('2026-01-01')) + ->delta())->toBe(100.0) + ->and($flatPrevious + ->between(Carbon::parse('2026-01-01'), Carbon::parse('2026-02-01')) + ->compareTo(Carbon::parse('2025-12-01'), Carbon::parse('2026-01-01')) + ->delta())->toBe(0.0); +}); diff --git a/server/tests/MiddlewareContractsTest.php b/server/tests/MiddlewareContractsTest.php new file mode 100644 index 000000000..fc6cfebc6 --- /dev/null +++ b/server/tests/MiddlewareContractsTest.php @@ -0,0 +1,38 @@ + null, + 'payload' => [ + 'pickup' => ['location' => null], + 'dropoff' => ['location' => [103.8, 1.3]], + 'meta' => ['location' => null], + ], + 'entities' => [ + ['name' => 'Box', 'location' => null], + ['name' => 'Crate', 'location' => [104.1, 1.4]], + ], + 'notes' => 'leave unchanged', + ]); + + $response = (new TransformLocationMiddleware())->handle($request, function (Request $request) { + return $request->all(); + }); + + expect($response)->toMatchArray([ + 'location' => [0, 0], + 'payload' => [ + 'pickup' => ['location' => [0, 0]], + 'dropoff' => ['location' => [103.8, 1.3]], + 'meta' => ['location' => [0, 0]], + ], + 'entities' => [ + ['name' => 'Box', 'location' => [0, 0]], + ['name' => 'Crate', 'location' => [104.1, 1.4]], + ], + 'notes' => 'leave unchanged', + ]); +}); diff --git a/server/tests/NotificationAndMailContractsTest.php b/server/tests/NotificationAndMailContractsTest.php new file mode 100644 index 000000000..a9255d573 --- /dev/null +++ b/server/tests/NotificationAndMailContractsTest.php @@ -0,0 +1,105 @@ + 15]); + $routeDeviation = new RouteDeviation($order, ['deviation_meters' => 400]); + $prolongedStoppage = new ProlongedStoppage($order, ['stopped_minutes' => 30]); + + expect(LateDeparture::$name)->toBe('Late Departure') + ->and(RouteDeviation::$package)->toBe('fleet-ops') + ->and(ProlongedStoppage::$description)->toContain('vehicle remains stopped') + ->and($lateDeparture->via(null))->toBe(['mail', 'database']) + ->and($routeDeviation->via(null))->toBe(['mail', 'database']) + ->and($prolongedStoppage->via(null))->toBe(['mail', 'database']) + ->and($lateDeparture->toArray(null))->toMatchArray([ + 'event' => 'order.late_departure', + 'order_id' => 'order_public', + 'order_uuid' => 'order-uuid', + 'context' => ['grace_minutes' => 15], + ]) + ->and($routeDeviation->toArray(null))->toMatchArray([ + 'event' => 'order.route_deviation', + 'order_id' => 'order_public', + 'order_uuid' => 'order-uuid', + 'context' => ['deviation_meters' => 400], + ]) + ->and($prolongedStoppage->toArray(null))->toMatchArray([ + 'event' => 'order.prolonged_stoppage', + 'order_id' => 'order_public', + 'order_uuid' => 'order-uuid', + 'context' => ['stopped_minutes' => 30], + ]); +}); + +test('work order dispatched mail exposes subject and markdown context', function () { + $workOrder = new WorkOrder(); + $workOrder->setRawAttributes([ + 'public_id' => 'WO-100', + 'subject' => 'Replace brake pads', + ], true); + $assignee = (object) ['name' => 'Fleet Vendor']; + $target = (object) ['display_name' => 'Truck 12']; + $workOrder->setRelation('assignee', $assignee); + $workOrder->setRelation('target', $target); + + $mail = new WorkOrderDispatched($workOrder); + $content = $mail->content(); + + expect($mail->workOrder)->toBe($workOrder) + ->and($mail->envelope()->subject)->toBe('Work Order #WO-100: Replace brake pads') + ->and($content->markdown)->toBe('fleetops::mail.work-order-dispatched') + ->and($content->with)->toMatchArray([ + 'workOrder' => $workOrder, + 'assignee' => $assignee, + 'target' => $target, + ]); +}); + +test('maintenance schedule reminder mail exposes schedule context', function () { + $schedule = new MaintenanceSchedule(); + $schedule->setRawAttributes([ + 'name' => 'Quarterly inspection', + ], true); + $subject = (object) ['display_name' => 'Truck 12']; + $assignee = (object) ['name' => 'Maintenance Vendor']; + $schedule->setRelation('subject', $subject); + $schedule->setRelation('defaultAssignee', $assignee); + + $mail = new MaintenanceScheduleReminder($schedule, 7); + $content = $mail->content(); + + expect($mail->schedule)->toBe($schedule) + ->and($mail->offsetDays)->toBe(7) + ->and($mail->envelope()->subject)->toContain('Maintenance Reminder: Quarterly inspection') + ->and($mail->envelope()->subject)->toContain('Truck 12') + ->and($content->markdown)->toBe('fleetops::mail.maintenance-schedule-reminder') + ->and($content->with)->toMatchArray([ + 'schedule' => $schedule, + 'assignee' => $assignee, + 'subject' => $subject, + 'offsetDays' => 7, + ]); +}); diff --git a/server/tests/ProviderContractsTest.php b/server/tests/ProviderContractsTest.php new file mode 100644 index 000000000..81e0e43a4 --- /dev/null +++ b/server/tests/ProviderContractsTest.php @@ -0,0 +1,109 @@ +getDefaultProperties()[$property]; +} + +test('fleetops service provider declares core observers and commands', function () { + $observers = providerDefaultProperty(FleetOpsServiceProvider::class, 'observers'); + $commands = providerDefaultProperty(FleetOpsServiceProvider::class, 'commands'); + + expect($observers)->toMatchArray([ + Order::class => OrderObserver::class, + Payload::class => PayloadObserver::class, + Driver::class => DriverObserver::class, + Vehicle::class => VehicleObserver::class, + Fleet::class => FleetObserver::class, + ]) + ->and($commands)->toContain( + DispatchOrders::class, + ProcessOperationalAlerts::class, + SyncTelematics::class + ); +}); + +test('fleetops service provider notification registry list includes operational alerts', function () { + $method = new ReflectionMethod(FleetOpsServiceProvider::class, 'registerNotifications'); + $source = file($method->getFileName()); + $body = implode('', array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); + + expect($body)->toContain( + OrderAssigned::class, + OrderCompletedNotification::class, + OrderPing::class, + LateDeparture::class, + RouteDeviation::class, + ProlongedStoppage::class + ) + ->and($body)->toContain( + Driver::class, + Fleet::class, + 'dynamic:customer', + 'dynamic:driver', + 'dynamic:facilitator' + ); +}); + +test('event service provider maps order geofence and schedule listeners', function () { + $source = file_get_contents(dirname(__DIR__) . '/src/Providers/EventServiceProvider.php'); + + expect($source)->toContain( + OrderDispatched::class, + HandleOrderDispatched::class, + OrderCompleted::class, + HandleDeliveryCompletion::class, + GeofenceEntered::class, + HandleGeofenceEntered::class, + GeofenceExited::class, + HandleGeofenceExited::class, + GeofenceDwelled::class, + HandleGeofenceDwelled::class, + UserRemovedFromCompany::class, + HandleUserRemovedFromCompany::class, + ScheduleItemCreated::class, + ScheduleItemUpdated::class, + NotifyDriverOnShiftChange::class, + SendResourceLifecycleWebhook::class, + NotifyOrderEvent::class + ); +}); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php new file mode 100644 index 000000000..1f3b7ce3e --- /dev/null +++ b/server/tests/RequestContractsTest.php @@ -0,0 +1,134 @@ +rule; + } + } + } +} + +namespace { + +use Fleetbase\FleetOps\Http\Requests\CreateDeviceRequest; +use Fleetbase\FleetOps\Http\Requests\CreateFuelReportRequest; +use Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest; +use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest; +use Fleetbase\FleetOps\Http\Requests\CreateWorkOrderRequest; +use Fleetbase\FleetOps\Http\Requests\UpdateDeviceRequest; +use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest; +use Fleetbase\FleetOps\Http\Requests\UpdateWorkOrderRequest; +use Fleetbase\FleetOps\Rules\ResolvablePoint; + +function requestRules(string $class, string $method = 'POST'): array +{ + return $class::create('/fleetops-test', $method)->rules(); +} + +function ruleStrings(array $rules): array +{ + return array_map(fn ($rule) => (string) $rule, $rules); +} + +test('device requests require names on create and protect paired location fields', function () { + $createRules = requestRules(CreateDeviceRequest::class); + $updateRules = requestRules(UpdateDeviceRequest::class, 'PATCH'); + + expect(ruleStrings($createRules['name']))->toContain('required', 'string') + ->and(ruleStrings($updateRules['name']))->not->toContain('required') + ->and($createRules['last_position'][1])->toBeInstanceOf(ResolvablePoint::class) + ->and($createRules['latitude'])->toBe(['nullable', 'required_with:longitude']) + ->and($createRules['longitude'])->toBe(['nullable', 'required_with:latitude']) + ->and($createRules['attachable'])->toBe(['nullable', 'required_with:attachable_type', 'string']) + ->and($createRules['meta'])->toBe(['nullable', 'array']) + ->and($createRules['options'])->toBe(['nullable', 'array']); +}); + +test('work order requests require subjects on create and preserve cost metadata rules', function () { + $createRules = requestRules(CreateWorkOrderRequest::class); + $updateRules = requestRules(UpdateWorkOrderRequest::class, 'PATCH'); + + expect(ruleStrings($createRules['subject']))->toContain('required', 'string') + ->and(ruleStrings($updateRules['subject']))->not->toContain('required') + ->and($createRules['target'])->toBe(['nullable', 'required_with:target_type', 'string']) + ->and($createRules['assignee'])->toBe(['nullable', 'required_with:assignee_type', 'string']) + ->and($createRules['currency'])->toBe(['nullable', 'string', 'size:3']) + ->and($createRules['checklist'])->toBe(['nullable', 'array']) + ->and($createRules['cost_breakdown'])->toBe(['nullable', 'array']) + ->and($createRules['meta'])->toBe(['nullable', 'array']); +}); + +test('fuel transaction request requires provider identifiers on create', function () { + $createRules = requestRules(CreateFuelTransactionRequest::class); + $patchRules = requestRules(CreateFuelTransactionRequest::class, 'PATCH'); + + expect(ruleStrings($createRules['provider']))->toContain('required', 'string') + ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string') + ->and(ruleStrings($patchRules['provider']))->not->toContain('required') + ->and(ruleStrings($patchRules['provider_transaction_id']))->not->toContain('required') + ->and($createRules['station_latitude'])->toBe(['nullable', 'numeric']) + ->and($createRules['station_longitude'])->toBe(['nullable', 'numeric']) + ->and($createRules['normalized_payload'])->toBe(['nullable', 'array']) + ->and($createRules['raw_payload'])->toBe(['nullable', 'array']) + ->and($createRules['meta'])->toBe(['nullable', 'array']); +}); + +test('vehicle and fuel report requests expose core validation contracts', function () { + $vehicleRules = requestRules(CreateVehicleRequest::class); + $fuelReportRules = requestRules(CreateFuelReportRequest::class); + $fuelReportUpdateRules = requestRules(UpdateFuelReportRequest::class, 'PATCH'); + + expect($vehicleRules['location'][1])->toBeInstanceOf(ResolvablePoint::class) + ->and($vehicleRules['latitude'])->toBe(['nullable', 'required_with:longitude']) + ->and($vehicleRules['longitude'])->toBe(['nullable', 'required_with:latitude']) + ->and(ruleStrings($vehicleRules['status']))->toContain('nullable') + ->and(implode('|', ruleStrings($vehicleRules['status'])))->toContain('operational') + ->and($fuelReportRules['driver'])->toBe(['required']) + ->and($fuelReportRules['odometer'])->toBe(['required']) + ->and($fuelReportRules['volume'])->toBe(['required']) + ->and($fuelReportUpdateRules['driver'])->toBe(['required']); +}); +} diff --git a/server/tests/RulesAndCastsTest.php b/server/tests/RulesAndCastsTest.php new file mode 100644 index 000000000..78cfc1d8a --- /dev/null +++ b/server/tests/RulesAndCastsTest.php @@ -0,0 +1,74 @@ +passes('customer', ['name' => 'Jane Customer']))->toBeTrue() + ->and($rule->passes('customer', ['email' => 'jane@example.test']))->toBeTrue() + ->and($rule->passes('customer', ['name' => 'Jane Customer', 'email' => 'jane@example.test']))->toBeTrue() + ->and($rule->message())->toBe('The :attribute is invalid.'); +}); + +test('customer detail validation reports specific shape errors', function () { + $rule = new CustomerIdOrDetails(); + + expect($rule->passes('customer', []))->toBeFalse() + ->and($rule->message())->toBe('The :attribute must have at least a name and email.') + ->and($rule->passes('customer', ['email' => 'not-an-email']))->toBeFalse() + ->and($rule->message())->toBe('The :attribute email must be a valid email address.') + ->and($rule->passes('customer', ['name' => ' ']))->toBeFalse() + ->and($rule->message())->toBe('The :attribute name must be a non-empty string.') + ->and($rule->passes('customer', false))->toBeFalse() + ->and($rule->message())->toBe('The :attribute must be either a string (customer ID) or an object with name and/or email.'); +}); + +test('computable algorithm rule delegates to algorithm evaluation', function () { + $rule = new ComputableAlgo(); + + expect($rule->passes('algorithm', '{base_fee} + max({distance_km}, 10)'))->toBeTrue() + ->and($rule->passes('algorithm', '{base_fee} +'))->toBeFalse() + ->and($rule->message())->toBe('Algorithm provided is not computable.'); +}); + +test('resolvable vehicle extracts supported identifier shapes without hitting the database for empty values', function () { + $rule = new ResolvableVehicle(); + $method = new ReflectionMethod($rule, 'extractIdentifier'); + $method->setAccessible(true); + + expect($method->invoke($rule, 'vehicle_123'))->toBe('vehicle_123') + ->and($method->invoke($rule, ['id' => 'vehicle_id']))->toBe('vehicle_id') + ->and($method->invoke($rule, ['public_id' => 'vehicle_public']))->toBe('vehicle_public') + ->and($method->invoke($rule, ['uuid' => 'vehicle_uuid']))->toBe('vehicle_uuid') + ->and($method->invoke($rule, (object) ['public_id' => 'vehicle_object']))->toBe('vehicle_object') + ->and($method->invoke($rule, false))->toBeNull() + ->and($rule->passes('vehicle', null))->toBeTrue() + ->and($rule->getResolved())->toBeNull() + ->and($rule->message())->toBe('The :attribute must be a valid vehicle public ID, UUID, or vehicle object.'); +}); + +test('order config entity cast serializes arrays for storage', function () { + $cast = new OrderConfigEntities(); + $data = [ + ['name' => 'Parcel', 'type' => 'parcel'], + ['name' => 'Pallet', 'type' => 'pallet'], + ]; + + expect($cast->set(new stdClass(), 'entities', $data, []))->toBe(json_encode($data)); +}); + +test('polygon casts reject unsupported values with helpful field names', function () { + $model = new stdClass(); + $model->geometries = []; + + expect(fn () => (new Polygon())->set($model, 'border', 'invalid', [])) + ->toThrow(Exception::class, 'Invalid Polygon provided for border') + ->and(fn () => (new MultiPolygon())->set($model, 'coverage_area', 'invalid', [])) + ->toThrow(Exception::class, 'Invalid MultiPolygon provided for coverage_area'); +}); diff --git a/server/tests/SupportServiceContractsTest.php b/server/tests/SupportServiceContractsTest.php new file mode 100644 index 000000000..923a84588 --- /dev/null +++ b/server/tests/SupportServiceContractsTest.php @@ -0,0 +1,183 @@ +calls[] = ['remember', $key, $ttl]; + + return $this->rememberValue ?? $callback(); + } + + public function flush() + { + $this->calls[] = ['flush']; + + if ($this->throwOnFlush) { + throw new RuntimeException('tagged cache is unavailable'); + } + } +} + +class FleetOpsLiveCacheFake +{ + public array $gets = []; + public array $increments = []; + public array $tags = []; + public ?FleetOpsTaggedCacheFake $taggedCache = null; + public bool $throwOnTags = false; + private array $versions = [ + 'live:company-1:orders:version' => 3, + 'live:company-1:drivers:version' => 1, + ]; + + public function get($key, $default = null) + { + $this->gets[] = [$key, $default]; + + return $this->versions[$key] ?? $default; + } + + public function increment($key) + { + $this->increments[] = $key; + + $this->versions[$key] = ($this->versions[$key] ?? 0) + 1; + + return $this->versions[$key]; + } + + public function tags(array $tags) + { + $this->tags[] = $tags; + + if ($this->throwOnTags) { + throw new RuntimeException('tagged cache is unavailable'); + } + + return $this->taggedCache ??= new FleetOpsTaggedCacheFake(); + } +} + +class FleetOpsDriverScopeBuilderFake extends Builder +{ + public array $whereHas = []; + + public function __construct() + { + } + + public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) + { + $this->whereHas[] = [$relation, $callback, $operator, $count]; + + return $this; + } +} + +test('live cache service builds company scoped keys and tags', function () { + session(['company' => 'company-1']); + + $cache = new FleetOpsLiveCacheFake(); + Cache::swap($cache); + + $params = ['active' => true, 'bounds' => [1, 2, 3, 4]]; + + expect(LiveCacheService::getCacheKey('orders', $params)) + ->toBe('live:company-1:orders:v3:' . md5(json_encode($params))) + ->and(LiveCacheService::getTags())->toBe(['live:company-1']) + ->and(LiveCacheService::getEndpointTags('orders'))->toBe([ + 'live:company-1', + 'live:company-1:orders', + ]) + ->and($cache->gets)->toBe([['live:company-1:orders:version', 0]]); +}); + +test('live cache service increments versions and remembers tagged values', function () { + session(['company' => 'company-1']); + + $cache = new FleetOpsLiveCacheFake(); + $cache->taggedCache = new FleetOpsTaggedCacheFake(); + Cache::swap($cache); + + expect(LiveCacheService::incrementVersion('drivers'))->toBe(2) + ->and(LiveCacheService::remember('drivers', ['limit' => 25], fn () => ['fresh' => true], 45))->toBe([ + 'fresh' => true, + ]) + ->and($cache->increments)->toBe(['live:company-1:drivers:version']) + ->and($cache->tags)->toBe([['live:company-1', 'live:company-1:drivers']]) + ->and($cache->taggedCache->calls)->toBe([ + ['remember', 'live:company-1:drivers:v2:' . md5(json_encode(['limit' => 25])), 45], + ]); +}); + +test('live cache service invalidates endpoints with version bumps and optional tag flushes', function () { + session(['company' => 'company-1']); + + $cache = new FleetOpsLiveCacheFake(); + $cache->taggedCache = new FleetOpsTaggedCacheFake(); + Cache::swap($cache); + + LiveCacheService::invalidate('vehicles'); + + expect($cache->increments)->toBe(['live:company-1:vehicles:version']) + ->and($cache->tags)->toBe([['live:company-1', 'live:company-1:vehicles']]) + ->and($cache->taggedCache->calls)->toBe([['flush']]); +}); + +test('live cache service invalidates all endpoints and ignores unsupported tag flushes', function () { + session(['company' => 'company-1']); + + $cache = new FleetOpsLiveCacheFake(); + $cache->throwOnTags = true; + Cache::swap($cache); + + LiveCacheService::invalidate(); + + expect($cache->increments)->toBe([ + 'live:company-1:orders:version', + 'live:company-1:routes:version', + 'live:company-1:coordinates:version', + 'live:company-1:drivers:version', + 'live:company-1:vehicles:version', + 'live:company-1:places:version', + 'live:company-1:operations-monitor:version', + ])->and($cache->tags)->toBe([['live:company-1']]); +}); + +test('live cache service invalidates multiple endpoints and ignores tag flush failures', function () { + session(['company' => 'company-1']); + + $cache = new FleetOpsLiveCacheFake(); + $cache->throwOnTags = true; + Cache::swap($cache); + + LiveCacheService::invalidateMultiple(['orders', 'drivers']); + + expect($cache->increments)->toBe([ + 'live:company-1:orders:version', + 'live:company-1:drivers:version', + ])->and($cache->tags)->toBe([['live:company-1', 'live:company-1:orders']]); +}); + +test('driver scope requires an active user relationship', function () { + $builder = new FleetOpsDriverScopeBuilderFake(); + $model = new class extends Model { + }; + + expect((new DriverScope())->apply($builder, $model))->toBeNull() + ->and($builder->whereHas)->toHaveCount(1) + ->and($builder->whereHas[0][0])->toBe('user'); +}); diff --git a/server/tests/SupportValueObjectsTest.php b/server/tests/SupportValueObjectsTest.php new file mode 100644 index 000000000..5ef4c79fb --- /dev/null +++ b/server/tests/SupportValueObjectsTest.php @@ -0,0 +1,142 @@ + 'fleet_card', + 'description' => 'Fleet card transactions.', + 'required_fields' => [['name' => 'api_key']], + 'capabilities' => ['vehicles', 'transactions'], + 'sync_defaults' => ['window_days' => 14], + 'setup_instructions' => ['Connect the account.'], + 'metadata' => ['region' => 'mena'], + ]); + + expect($descriptor->label)->toBe('fleet_card') + ->and($descriptor->type)->toBe('native') + ->and($descriptor->driverClass)->toBeNull() + ->and($descriptor->docsUrl)->toBeNull() + ->and($descriptor->category)->toBeNull() + ->and($descriptor->icon)->toBe('gas-pump') + ->and($descriptor->toArray())->toMatchArray([ + 'key' => 'fleet_card', + 'label' => 'fleet_card', + 'type' => 'native', + 'description' => 'Fleet card transactions.', + 'required_fields' => [['name' => 'api_key']], + 'capabilities' => ['vehicles', 'transactions'], + 'sync_defaults' => ['window_days' => 14], + 'setup_instructions' => ['Connect the account.'], + 'metadata' => ['region' => 'mena'], + ]); +}); + +test('telematic provider descriptors include defaults and webhook metadata', function () { + $previousApp = Illuminate\Container\Container::getInstance(); + Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + return false; + } + }); + + $descriptor = new TelematicProviderDescriptor([ + 'key' => 'safee', + 'label' => 'Safee', + 'driver_class' => TestTelematicDriver::class, + 'required_fields' => [['name' => 'token']], + 'supports_webhooks' => true, + 'supports_discovery' => true, + 'metadata' => ['region' => 'global'], + ]); + + try { + $array = $descriptor->toArray(); + + expect($descriptor->type)->toBe('native') + ->and($descriptor->icon)->toBe(TelematicProviderDescriptor::DEFAULT_ICON) + ->and($array)->toMatchArray([ + 'key' => 'safee', + 'label' => 'Safee', + 'type' => 'native', + 'required_fields' => [['name' => 'token']], + 'supports_webhooks' => true, + 'supports_discovery' => true, + 'metadata' => ['region' => 'global'], + ]) + ->and($array['webhook_url'])->toContain('webhooks/telematics/safee') + ->and(json_decode($descriptor->toJson(), true))->toMatchArray($array); + } finally { + Illuminate\Container\Container::setInstance($previousApp); + } +}); + +test('tracking provider capabilities merge standard and provider-specific capabilities', function () { + $capabilities = new TrackingProviderCapabilities( + traffic: true, + perLegEta: true, + mapMatching: false, + routeGeometry: true, + extras: ['snap_to_road' => true], + ); + + expect($capabilities->toArray())->toBe([ + 'traffic' => true, + 'per_leg_eta' => true, + 'map_matching' => false, + 'route_geometry' => true, + 'snap_to_road' => true, + ])->and($capabilities->jsonSerialize())->toBe($capabilities->toArray()); +}); + +test('point cast helpers normalize coordinate geometry and raw binary detection', function () { + expect(Point::coordinatesBboxToFloat([ + 'type' => 'Point', + 'coordinates' => ['106.917', '47.918'], + 'bbox' => ['106', '47', '107', '48'], + ]))->toBe([ + 'type' => 'Point', + 'coordinates' => [106.917, 47.918], + 'bbox' => [106.0, 47.0, 107.0, 48.0], + ]) + ->and(Point::isRawPoint("abc\x00def"))->toBeTrue() + ->and(Point::isRawPoint('plain text'))->toBeFalse() + ->and(Point::isRawPoint(['not' => 'a string']))->toBeFalse() + ->and(Point::hex2str('48656c6c6f'))->toBe('Hello'); +}); + +test('polyline encoding round trips flattened and paired coordinate lists', function () { + $points = [[38.5, -120.2], [40.7, -120.95], [43.252, -126.453]]; + $encoded = Polyline::encode($points); + $decoded = Polyline::decode($encoded); + + expect($encoded)->toBe('_p~iF~ps|U_ulLnnqC_mqNvxq`@') + ->and(Polyline::flatten($points))->toBe([38.5, -120.2, 40.7, -120.95, 43.252, -126.453]) + ->and($decoded)->toHaveCount(3) + ->and($decoded[0]->getLng())->toBe(38.5) + ->and($decoded[0]->getLat())->toBe(-120.2) + ->and($decoded[2]->getLng())->toBe(43.252) + ->and($decoded[2]->getLat())->toBe(-126.453) + ->and(Polyline::pair([1, 2, 3, 4]))->toBe([[1, 2], [3, 4]]) + ->and(Polyline::pair('not a list'))->toBe([]); +}); + + class TestTelematicDriver + { + } +} diff --git a/server/tests/TelematicProviderRegistryTest.php b/server/tests/TelematicProviderRegistryTest.php new file mode 100644 index 000000000..b0c959544 --- /dev/null +++ b/server/tests/TelematicProviderRegistryTest.php @@ -0,0 +1,167 @@ +credentials; + } + + public function exposedRateLimitKey(): string + { + return $this->rateLimitKey(); + } + + protected function prepareAuthentication(): void + { + $this->headers['Authorization'] = 'Bearer ' . ($this->credentials['token'] ?? 'missing'); + } + + public function testConnection(array $credentials): array + { + return ['success' => true, 'message' => 'ok', 'metadata' => $credentials]; + } + + public function fetchDevices(array $options = []): array + { + return ['devices' => [], 'next_cursor' => null, 'has_more' => false]; + } + + public function fetchDeviceDetails(string $externalId): array + { + return ['external_id' => $externalId]; + } + + public function normalizeDevice(array $payload): array + { + return $payload; + } + + public function normalizeEvent(array $payload): array + { + return $payload; + } + + public function normalizeSensor(array $payload): array + { + return $payload; + } +} + +test('telematic provider registry stores filters and resolves provider drivers', function () { + $registry = new TelematicProviderRegistry(); + + $native = new TelematicProviderDescriptor([ + 'key' => 'native_provider', + 'label' => 'Native Provider', + 'driver_class' => TestTelematicProviderForRegistry::class, + 'supports_webhooks' => true, + 'supports_discovery' => true, + ]); + $custom = new TelematicProviderDescriptor([ + 'key' => 'custom_provider', + 'label' => 'Custom Provider', + 'type' => 'custom', + 'driver_class' => TestTelematicProviderForRegistry::class, + 'supports_webhooks' => false, + 'supports_discovery' => false, + ]); + + $registry->register($native); + $registry->register($custom); + + expect($registry->has('native_provider'))->toBeTrue() + ->and($registry->findByKey('native_provider'))->toBe($native) + ->and($registry->all()->only(['native_provider', 'custom_provider'])->all())->toBe([ + 'native_provider' => $native, + 'custom_provider' => $custom, + ]) + ->and($registry->getWebhookProviders()->keys()->all())->toContain('native_provider') + ->and($registry->getDiscoveryProviders()->keys()->all())->toContain('native_provider') + ->and($registry->getNativeProviders()->keys()->all())->toContain('native_provider') + ->and($registry->getCustomProviders()->keys()->all())->toContain('custom_provider') + ->and($registry->resolve('native_provider'))->toBeInstanceOf(TestTelematicProviderForRegistry::class); +}); + +test('telematic provider registry reports invalid resolve states clearly', function () { + $registry = new TelematicProviderRegistry(); + $registry->register(new TelematicProviderDescriptor([ + 'key' => 'missing_driver', + 'label' => 'Missing Driver', + ])); + $registry->register(new TelematicProviderDescriptor([ + 'key' => 'unknown_class', + 'label' => 'Unknown Class', + 'driver_class' => 'Fleetbase\\FleetOps\\Tests\\MissingTelematicProvider', + ])); + $registry->register(new TelematicProviderDescriptor([ + 'key' => 'wrong_contract', + 'label' => 'Wrong Contract', + 'driver_class' => stdClass::class, + ])); + + expect(fn () => $registry->resolve('not_registered')) + ->toThrow(InvalidArgumentException::class, "Provider 'not_registered' not found in registry.") + ->and(fn () => $registry->resolve('missing_driver')) + ->toThrow(InvalidArgumentException::class, "Provider 'missing_driver' does not have a driver class.") + ->and(fn () => $registry->resolve('unknown_class')) + ->toThrow(InvalidArgumentException::class, "Provider driver class 'Fleetbase\\FleetOps\\Tests\\MissingTelematicProvider' does not exist.") + ->and(fn () => $registry->resolve('wrong_contract')) + ->toThrow(InvalidArgumentException::class, 'Provider driver class must implement TelematicProviderInterface.'); +}); + +test('abstract telematic provider resolves array and json credentials', function () { + $arrayTelematic = new Telematic(); + $arrayTelematic->setRawAttributes([ + 'uuid' => 'telematic-array', + 'credentials' => ['token' => 'array-token'], + ], true); + + $jsonTelematic = new Telematic(); + $jsonTelematic->setRawAttributes([ + 'uuid' => 'telematic-json', + 'credentials' => json_encode(['token' => 'json-token']), + ], true); + + $emptyTelematic = new Telematic(); + $emptyTelematic->setRawAttributes([ + 'uuid' => 'telematic-empty', + 'credentials' => null, + ], true); + + $provider = new TestTelematicProviderForRegistry(); + $provider->connect($arrayTelematic); + expect($provider->exposedCredentials())->toBe(['token' => 'array-token']) + ->and($provider->exposedRateLimitKey())->toBe('rate_limit:TestTelematicProviderForRegistry:telematic-array'); + + $provider->connect($jsonTelematic); + expect($provider->exposedCredentials())->toBe(['token' => 'json-token']); + + $provider->connect($emptyTelematic); + expect($provider->exposedCredentials())->toBe([]); +}); + +test('abstract telematic provider exposes conservative default capabilities', function () { + $provider = new TestTelematicProviderForRegistry(); + + expect($provider)->toBeInstanceOf(TelematicProviderInterface::class) + ->and($provider->supportsWebhooks())->toBeFalse() + ->and($provider->supportsDiscovery())->toBeTrue() + ->and($provider->getRateLimits())->toBe([ + 'requests_per_minute' => 60, + 'burst_size' => 10, + ]) + ->and($provider->validateWebhookSignature('payload', 'signature', []))->toBeFalse() + ->and($provider->processWebhook(['event' => 'test']))->toBe([ + 'devices' => [], + 'events' => [], + 'sensors' => [], + ]) + ->and($provider->getCredentialSchema())->toBe([]); +}); diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php index beab42fd6..3d667dbc2 100644 --- a/server/tests/TrackingIntelligenceTest.php +++ b/server/tests/TrackingIntelligenceTest.php @@ -5,12 +5,14 @@ use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider; +use Fleetbase\FleetOps\Tracking\TrackingContext; use Fleetbase\FleetOps\Tracking\Support\FakeTrackingProvider; use Fleetbase\FleetOps\Tracking\TrackingContextBuilder; use Fleetbase\FleetOps\Tracking\TrackingOptions; use Fleetbase\FleetOps\Tracking\TrackingProviderManager; use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; use Fleetbase\FleetOps\Tracking\TrackingProviderResult; +use Fleetbase\FleetOps\Tracking\TrackingStop; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Support\Carbon; @@ -133,6 +135,37 @@ function trackingOrderWithStops(): Order ->and($result->warnings)->toContain('calculated_route_used'); }); +test('calculated provider requires enough route points and guards minimum speed', function () { + $emptyContext = new TrackingContext( + order: trackingModel(TrackingTestOrder::class), + payload: null, + driver: null, + origin: null, + driverLocation: null, + stops: collect(), + completedStops: collect(), + remainingStops: collect(), + activeStop: null, + nextStop: null, + driverLocationAgeSeconds: null, + ); + $provider = new CalculatedTrackingProvider(); + $method = new ReflectionMethod($provider, 'durationFromDistance'); + $method->setAccessible(true); + + expect($provider->key())->toBe('calculated') + ->and($provider->capabilities()->toArray())->toBe([ + 'traffic' => false, + 'per_leg_eta' => false, + 'map_matching' => false, + 'route_geometry' => false, + ]) + ->and($provider->canTrack($emptyContext))->toBeFalse() + ->and($method->invoke($provider, 1000.0, TrackingOptions::fromArray([ + 'default_vehicle_speed_kph' => 0, + ])))->toBe(3600.0); +}); + test('tracking route legs include cumulative stop eta values', function () { $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([ 'provider' => 'calculated', @@ -173,6 +206,47 @@ function trackingOrderWithStops(): Order ->and($result->warnings)->toContain('fallback_used'); }); +test('provider manager returns none result with accumulated warnings when providers are unavailable', function () { + $manager = new TrackingProviderManager(new TrackingProviderRegistry()); + $context = new TrackingContext( + order: trackingModel(TrackingTestOrder::class), + payload: null, + driver: null, + origin: null, + driverLocation: null, + stops: collect(), + completedStops: collect(), + remainingStops: collect(), + activeStop: null, + nextStop: null, + driverLocationAgeSeconds: null, + ); + + $result = $manager->track($context, TrackingOptions::fromArray([ + 'provider' => 'missing', + 'fallbacks' => ['calculated'], + ])); + + expect($result->provider)->toBe('none') + ->and($result->confidence)->toBe('none') + ->and($result->warnings)->toContain('provider_not_registered:missing') + ->and($result->warnings)->toContain('provider_not_registered:calculated') + ->and($result->warnings)->toContain('no_tracking_provider_available'); +}); + +test('provider manager normalizes and deduplicates provider order', function () { + $manager = new TrackingProviderManager(new TrackingProviderRegistry()); + $method = new ReflectionMethod($manager, 'providerOrder'); + $method->setAccessible(true); + + $order = $method->invoke($manager, TrackingOptions::fromArray([ + 'provider' => 'Google Routes', + 'fallbacks' => ['google_routes', 'OSRM', 'calculated', 'OSRM'], + ])); + + expect($order)->toBe(['google_routes', 'osrm', 'calculated']); +}); + test('third party providers can be registered through the tracking provider contract', function () { $registry = new TrackingProviderRegistry(); $registry->register(new FakeTrackingProvider('tomtom')); @@ -193,6 +267,97 @@ function trackingOrderWithStops(): Order ->and($options->routeCacheTtlSeconds)->toBe(900); }); +test('tracking options normalize explicit scalar settings', function () { + $options = TrackingOptions::fromArray([ + 'provider' => 'google_routes', + 'fallbacks' => ['osrm'], + 'traffic_enabled' => false, + 'cache_ttl_seconds' => '45', + 'route_cache_ttl_seconds' => '1200', + 'stale_location_threshold_seconds' => '180', + 'default_vehicle_speed_kph' => '42.5', + ]); + + expect($options->provider)->toBe('google_routes') + ->and($options->fallbacks)->toBe(['osrm']) + ->and($options->trafficEnabled)->toBeFalse() + ->and($options->cacheTtlSeconds)->toBe(45) + ->and($options->routeCacheTtlSeconds)->toBe(1200) + ->and($options->staleLocationThresholdSeconds)->toBe(180) + ->and($options->defaultVehicleSpeedKph)->toBe(42.5) + ->and($options->raw['provider'])->toBe('google_routes'); +}); + +test('tracking result stores provider route details without mutation', function () { + $result = new TrackingProviderResult( + provider: 'osrm', + distanceMeters: 1234.5, + durationSeconds: 600.0, + durationInTrafficSeconds: 720.0, + polyline: 'encoded-route', + coordinates: [[103.8, 1.3]], + legs: [['distance_m' => 1234.5]], + warnings: ['traffic_unavailable'], + confidence: 'high', + raw: ['source' => 'fixture'], + ); + + expect($result->provider)->toBe('osrm') + ->and($result->distanceMeters)->toBe(1234.5) + ->and($result->durationSeconds)->toBe(600.0) + ->and($result->durationInTrafficSeconds)->toBe(720.0) + ->and($result->polyline)->toBe('encoded-route') + ->and($result->coordinates)->toBe([[103.8, 1.3]]) + ->and($result->legs)->toBe([['distance_m' => 1234.5]]) + ->and($result->warnings)->toBe(['traffic_unavailable']) + ->and($result->confidence)->toBe('high') + ->and($result->raw)->toBe(['source' => 'fixture']); +}); + +test('tracking stops serialize empty place data safely', function () { + $stop = new TrackingStop( + uuid: 'stop-1', + publicId: 'stop_public', + type: 'dropoff', + status: 'pending', + place: null, + completed: false, + sequence: 2, + trackingNumberUuid: 'tracking-1', + ); + + expect($stop->point())->toBeNull() + ->and($stop->toArray())->toMatchArray([ + 'uuid' => 'stop-1', + 'public_id' => 'stop_public', + 'type' => 'dropoff', + 'status' => 'pending', + 'completed' => false, + 'sequence' => 2, + 'tracking_number_uuid' => 'tracking-1', + 'address' => null, + 'name' => null, + 'location' => null, + 'latitude' => null, + 'longitude' => null, + ]) + ->and($stop->jsonSerialize())->toBe($stop->toArray()); +}); + +test('tracking provider registry normalizes custom keys and returns all providers', function () { + $provider = new FakeTrackingProvider('google-routes'); + $registry = new TrackingProviderRegistry(); + + $returned = $registry->register($provider, 'Google Routes'); + + expect($returned)->toBe($registry) + ->and($registry->has('google_routes'))->toBeTrue() + ->and($registry->has('Google Routes'))->toBeTrue() + ->and($registry->get('google_routes'))->toBe($provider) + ->and($registry->all())->toBe(['google_routes' => $provider]) + ->and($registry->get('missing'))->toBeNull(); +}); + test('provider cache key varies by route options', function () { $registry = new TrackingProviderRegistry(); $manager = new TrackingProviderManager($registry); diff --git a/server/tests/UtilsContractsTest.php b/server/tests/UtilsContractsTest.php new file mode 100644 index 000000000..84b0034d0 --- /dev/null +++ b/server/tests/UtilsContractsTest.php @@ -0,0 +1,118 @@ +toBeTrue() + ->and(Utils::isLatitude('-90'))->toBeTrue() + ->and(Utils::isLatitude('90.0001'))->toBeFalse() + ->and(Utils::isLatitude(null))->toBeFalse() + ->and(Utils::isLongitude('106.9338169'))->toBeTrue() + ->and(Utils::isLongitude('-180'))->toBeTrue() + ->and(Utils::isLongitude('180.1'))->toBeFalse() + ->and(Utils::isLongitude('east'))->toBeFalse() + ->and(Utils::cleanCoordinateString(' +47.913, (106.933)!'))->toBe('47.913106.933'); +}); + +test('utility address formatter composes plain text and html addresses without duplicate parts', function () { + $place = new \Fleetbase\FleetOps\Models\Place(); + $place->setRawAttributes([ + 'name' => 'Depot', + 'street1' => '123 MAIN STREET', + 'street2' => 'Main Street', + 'city' => 'Ulaanbaatar', + 'province' => 'Ulaanbaatar', + 'postal_code' => '14200', + 'country_name' => 'Mongolia', + ], true); + + expect(Utils::getAddressStringForPlace($place))->toBe('DEPOT - 123 MAIN STREET, ULAANBAATAR, 14200, MONGOLIA') + ->and(Utils::getAddressStringForPlace($place, true, ['postal_code'])) + ->toBe('
DEPOT
123 MAIN STREET
ULAANBAATAR, MONGOLIA
'); +}); + +test('utility point resolvers parse common coordinate payloads', function () { + $geoJson = [ + 'type' => 'Point', + 'coordinates' => [106.9338169, 47.9131423], + ]; + + $feature = [ + 'type' => 'Feature', + 'geometry' => $geoJson, + ]; + + expect(Utils::isGeoJson($geoJson))->toBeTrue() + ->and(Utils::isGeoJson(['type' => 'GeometryCollection', 'geometries' => []]))->toBeTrue() + ->and(Utils::isGeoJson(['type' => 'Point']))->toBeFalse() + ->and(Utils::isCoordinates($geoJson))->toBeTrue() + ->and(Utils::isCoordinatesStrict('47.9131423,106.9338169'))->toBeTrue() + ->and(Utils::isCoordinatesStrict('place_123'))->toBeFalse(); + + $pointFromGeoJson = Utils::getPointFromMixed($geoJson); + $pointFromFeature = Utils::getPointFromCoordinatesStrict($feature); + $pointFromWkt = Utils::getPointFromMixed('POINT(106.9338169 47.9131423)'); + $pointFromLatLng = Utils::getPointFromMixed('LatLng(47.9131423, 106.9338169)'); + + expect($pointFromGeoJson)->toBeInstanceOf(Point::class) + ->and($pointFromGeoJson->getLat())->toEqual(47.9131423) + ->and($pointFromGeoJson->getLng())->toEqual(106.9338169) + ->and($pointFromFeature)->toBeInstanceOf(Point::class) + ->and($pointFromFeature->getLat())->toEqual(47.9131423) + ->and($pointFromWkt->getLat())->toEqual(47.9131423) + ->and($pointFromWkt->getLng())->toEqual(106.9338169) + ->and($pointFromLatLng->getLat())->toEqual(47.9131423) + ->and($pointFromLatLng->getLng())->toEqual(106.9338169); +}); + +test('utility coordinate helpers extract coordinates and fall back to origin safely', function () { + $point = new Point(47.9131423, 106.9338169); + + expect(Utils::getLatitudeFromCoordinates($point))->toEqual(47.9131423) + ->and(Utils::getLongitudeFromCoordinates(['lat' => 47.9131423, 'lng' => 106.9338169]))->toEqual(106.9338169) + ->and(Utils::getPointFromCoordinates(null)->getLat())->toEqual(0.0) + ->and(Utils::getPointFromCoordinates(null)->getLng())->toEqual(0.0) + ->and(Utils::castPoint('not coordinates')->getLat())->toEqual(0.0) + ->and(Utils::castPoint('not coordinates')->getLng())->toEqual(0.0) + ->and(Utils::createCoordinateStringFromPlacesArray([ + ['lat' => 47.9131423, 'lng' => 106.9338169], + 'invalid', + ]))->toBe('47.9131423,106.9338169|0,0'); +}); + +test('utility geometry summaries and formatters return stable values', function () { + expect(Utils::formatMeters(999))->toBe('999 m') + ->and(Utils::formatMeters(1500, false))->toBe('1.5 kilometers') + ->and(Utils::getCentroid([]))->toBe([0, 0]) + ->and(Utils::getCentroid([['bad'], [10, 20], [30, 40]]))->toBe([20, 30]) + ->and(Utils::coordsToCircle(47.9131423, 106.9338169, 1))->toHaveCount(121) + ->and(Utils::getNearestTimezone(new Point(40.7128, -74.0060), 'US'))->toBe('America/New_York') + ->and(round(Utils::calculateHeading(new Point(0, 0), new Point(0, 1)), 1))->toEqual(90.0) + ->and(Utils::fixPhone('97612345678'))->toBe('+97612345678') + ->and(Utils::fixPhone('+97612345678'))->toBe('+97612345678'); +}); + +test('utility distance helpers calculate deterministic preliminary distance matrices', function () { + $origin = new Point(47.9131423, 106.9338169); + $destination = new Point(47.9141423, 106.9348169); + + $distance = Utils::vincentyGreatCircleDistance($origin, $destination); + $matrix = Utils::getPreliminaryDistanceMatrix($origin, $destination); + + expect($distance)->toBeGreaterThan(0) + ->and($matrix->distance)->toBe($distance) + ->and($matrix->time)->toBe((float) round($distance / 100) * Utils::DRIVING_TIME_MULTIPLIER); +}); + +test('utility type predicates recognize points and completed activities', function () { + $activity = new Activity(['code' => 'completed']); + $emptyActivity = new Activity(['code' => '']); + + expect(Utils::isPoint(new Point(0, 0)))->toBeTrue() + ->and(Utils::isPoint(['lat' => 0, 'lng' => 0]))->toBeFalse() + ->and(Utils::isActivity($activity))->toBeTrue() + ->and(Utils::isActivity($emptyActivity))->toBeFalse() + ->and(Utils::isActivity(null))->toBeFalse(); +}); diff --git a/server/tests/VehicleDataParsingTest.php b/server/tests/VehicleDataParsingTest.php new file mode 100644 index 000000000..3f377bd0a --- /dev/null +++ b/server/tests/VehicleDataParsingTest.php @@ -0,0 +1,45 @@ + [ + ['model' => 'Transit', 2015], + ['model' => 'F-150', 2015], + ], + 'Toyota' => [ + ['model' => 'Hiace', 2015], + ], + ]; + } +} + +test('vehicle data parser extracts known makes models and years', function () { + expect(VehicleDataHarness::parse('2018 Ford Transit refrigerated van'))->toBe([ + 'make' => 'Ford', + 'model' => 'Transit', + 'year' => '2018', + ]) + ->and(VehicleDataHarness::parse('Toyota Hiace 2020'))->toBe([ + 'make' => 'Toyota', + 'model' => 'Hiace', + 'year' => '2020', + ]); +}); + +test('vehicle data parser falls back to remaining text as model', function () { + expect(VehicleDataHarness::parse('2017 Ford custom box truck'))->toBe([ + 'make' => 'Ford', + 'model' => 'Custom Box Truck', + 'year' => '2017', + ]) + ->and(VehicleDataHarness::parse('Unlisted Yard Tractor'))->toBe([ + 'make' => null, + 'model' => 'Unlisted Yard Tractor', + 'year' => null, + ]); +});