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,
+ ]);
+});