@fleetbase/fleetops-engine 0.6.59 → 0.6.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +36 -0
- package/addon/components/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
- package/addon/services/vehicle-actions.js +4 -2
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +57 -2
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +1 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +32 -0
- package/server/tests/CompactResourceSerializationTest.php +93 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +97 -5
- package/server/tests/SmallControllerContractsTest.php +28 -4
- package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
|
@@ -307,6 +307,11 @@ class FleetOpsInternalDriverAuthControllerProbe extends DriverController
|
|
|
307
307
|
|
|
308
308
|
public static function resetProbe(): void
|
|
309
309
|
{
|
|
310
|
+
// verifyCode() defers to DriverController::verificationBypassMatches(), which
|
|
311
|
+
// asks app()->environment('production'). The bare container this harness binds
|
|
312
|
+
// has no such method, so swap it before any verify-code test runs.
|
|
313
|
+
fleetopsInternalDriverAuthContainer();
|
|
314
|
+
|
|
310
315
|
static::$loginUser = null;
|
|
311
316
|
static::$verificationUser = null;
|
|
312
317
|
static::$loginDriver = null;
|
|
@@ -531,6 +536,53 @@ function fleetopsInternalDriverExportSelections(DriverExport $export): array
|
|
|
531
536
|
return $property->getValue($export);
|
|
532
537
|
}
|
|
533
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Swap in a container that answers environment().
|
|
541
|
+
*
|
|
542
|
+
* Mirrors fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest and
|
|
543
|
+
* fleetopsDriverAuthContainer() in Api/DriverControllerAuthFlowsTest. Idempotent, so
|
|
544
|
+
* resetProbe() can call it per test.
|
|
545
|
+
*/
|
|
546
|
+
function fleetopsInternalDriverAuthContainer(): void
|
|
547
|
+
{
|
|
548
|
+
$current = Illuminate\Container\Container::getInstance();
|
|
549
|
+
if (method_exists($current, 'hasDebugModeEnabled')) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
$replacement = new class extends Illuminate\Container\Container {
|
|
554
|
+
public function environment(...$environments)
|
|
555
|
+
{
|
|
556
|
+
if (empty($environments)) {
|
|
557
|
+
return 'testing';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
$checks = is_array($environments[0]) ? $environments[0] : $environments;
|
|
561
|
+
|
|
562
|
+
return in_array('testing', $checks, true);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
public function hasDebugModeEnabled()
|
|
566
|
+
{
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
|
|
572
|
+
if (!property_exists(Illuminate\Container\Container::class, $property)) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
$reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
|
|
576
|
+
$reflection->setAccessible(true);
|
|
577
|
+
if ($reflection->isInitialized($current)) {
|
|
578
|
+
$reflection->setValue($replacement, $reflection->getValue($current));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
Illuminate\Container\Container::setInstance($replacement);
|
|
583
|
+
Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
|
|
584
|
+
}
|
|
585
|
+
|
|
534
586
|
function fleetopsInternalDriverUseHelperDatabase(): SQLiteConnection
|
|
535
587
|
{
|
|
536
588
|
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
@@ -993,6 +1045,9 @@ test('internal driver controller login with phone normalizes identity and handle
|
|
|
993
1045
|
|
|
994
1046
|
test('internal driver controller verify code covers missing user invalid code and missing driver branches', function () {
|
|
995
1047
|
app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
|
|
1048
|
+
// The bypass is only honoured for a listed identity. Both forms because
|
|
1049
|
+
// verifyCode normalises a non-email identity through static::phone().
|
|
1050
|
+
app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
|
|
996
1051
|
|
|
997
1052
|
FleetOpsInternalDriverAuthControllerProbe::resetProbe();
|
|
998
1053
|
$controller = new FleetOpsInternalDriverAuthControllerProbe();
|
|
@@ -1035,6 +1090,9 @@ test('internal driver controller verify code covers missing user invalid code an
|
|
|
1035
1090
|
|
|
1036
1091
|
test('internal driver controller verify code returns driver resource and handles token errors', function () {
|
|
1037
1092
|
app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
|
|
1093
|
+
// The bypass is only honoured for a listed identity. Both forms because
|
|
1094
|
+
// verifyCode normalises a non-email identity through static::phone().
|
|
1095
|
+
app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
|
|
1038
1096
|
|
|
1039
1097
|
FleetOpsInternalDriverAuthControllerProbe::resetProbe();
|
|
1040
1098
|
FleetOpsInternalDriverAuthControllerProbe::$verificationUser = fleetopsInternalDriverAuthUser();
|
|
@@ -178,4 +178,21 @@ test('fuel provider transaction real lookup scopes identifier and company', func
|
|
|
178
178
|
|
|
179
179
|
expect($reflection->invoke($controller, 'fpt-real-1')->public_id)->toBe('fuel_provider_transaction_real1')
|
|
180
180
|
->and($reflection->invoke($controller, 'fuel_provider_transaction_real1')->uuid)->toBe('fpt-real-1');
|
|
181
|
+
|
|
182
|
+
// resolveProvider() falls back to the stored provider when a PUT omits it, so the
|
|
183
|
+
// uniqueness scope is never null — a null scope would compare the transaction id
|
|
184
|
+
// against the wrong set of rows. Needs a real connection, which is why this lives
|
|
185
|
+
// here rather than in RequestContractsTest.
|
|
186
|
+
$resolveProvider = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::class, 'resolveProvider');
|
|
187
|
+
$resolveProvider->setAccessible(true);
|
|
188
|
+
|
|
189
|
+
$withRouteId = Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::create('/fleetops-test', 'PUT')
|
|
190
|
+
->setRouteResolver(fn () => new class {
|
|
191
|
+
public function parameter($key, $default = null)
|
|
192
|
+
{
|
|
193
|
+
return $key === 'id' ? 'fuel_provider_transaction_real1' : $default;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect($resolveProvider->invoke($withRouteId))->toBe('petroapp');
|
|
181
198
|
});
|
|
@@ -76,6 +76,18 @@ namespace Illuminate\Validation {
|
|
|
76
76
|
return $this;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Recorded so tests can assert the ignore-self clause on update
|
|
81
|
+
* requests, including which column it matches on — the v1 routes
|
|
82
|
+
* pass a public_id, not a uuid.
|
|
83
|
+
*/
|
|
84
|
+
public function ignore($id, $idColumn = null): self
|
|
85
|
+
{
|
|
86
|
+
$this->constraints[] = ['ignore', $id, $idColumn];
|
|
87
|
+
|
|
88
|
+
return $this;
|
|
89
|
+
}
|
|
90
|
+
|
|
79
91
|
public function __toString(): string
|
|
80
92
|
{
|
|
81
93
|
return $this->rule;
|
|
@@ -245,11 +257,16 @@ namespace {
|
|
|
245
257
|
});
|
|
246
258
|
|
|
247
259
|
test('fuel transaction request requires provider identifiers on create', function () {
|
|
260
|
+
// The harness `session()` stand-in is a process-wide static, so seed it
|
|
261
|
+
// here rather than relying on an earlier test in the file having done so.
|
|
262
|
+
session(['company' => 'company-uuid']);
|
|
263
|
+
bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
|
|
264
|
+
|
|
248
265
|
$createRules = requestRules(CreateFuelTransactionRequest::class);
|
|
249
266
|
$patchRules = requestRules(CreateFuelTransactionRequest::class, 'PATCH');
|
|
250
267
|
|
|
251
268
|
expect(ruleStrings($createRules['provider']))->toContain('required', 'string')
|
|
252
|
-
->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string')
|
|
269
|
+
->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string', 'unique:fuel_provider_transactions,provider_transaction_id')
|
|
253
270
|
->and(ruleStrings($patchRules['provider']))->not->toContain('required')
|
|
254
271
|
->and(ruleStrings($patchRules['provider_transaction_id']))->not->toContain('required')
|
|
255
272
|
->and($createRules['station_latitude'])->toBe(['nullable', 'numeric'])
|
|
@@ -257,6 +274,19 @@ namespace {
|
|
|
257
274
|
->and($createRules['normalized_payload'])->toBe(['nullable', 'array'])
|
|
258
275
|
->and($createRules['raw_payload'])->toBe(['nullable', 'array'])
|
|
259
276
|
->and($createRules['meta'])->toBe(['nullable', 'array']);
|
|
277
|
+
|
|
278
|
+
// Mirrors fuel_provider_txn_company_provider_unique. The provider is part
|
|
279
|
+
// of the scope, and company_uuid is what stops one tenant's provider
|
|
280
|
+
// transaction id from colliding with another's — the old index was global.
|
|
281
|
+
$rulesWithProvider = CreateFuelTransactionRequest::create('/fleetops-test', 'POST', ['provider' => 'petroapp'])->rules();
|
|
282
|
+
$uniqueRule = collect($rulesWithProvider['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
|
|
283
|
+
|
|
284
|
+
expect($uniqueRule)->not->toBeNull()
|
|
285
|
+
->and($uniqueRule->constraints)->toBe([
|
|
286
|
+
['where', 'company_uuid', 'company-uuid'],
|
|
287
|
+
['where', 'provider', 'petroapp'],
|
|
288
|
+
['whereNull', 'deleted_at'],
|
|
289
|
+
]);
|
|
260
290
|
});
|
|
261
291
|
|
|
262
292
|
test('vehicle and fuel report requests expose core validation contracts', function () {
|
|
@@ -273,7 +303,13 @@ namespace {
|
|
|
273
303
|
->and($fuelReportRules['driver'])->toBe(['required'])
|
|
274
304
|
->and($fuelReportRules['odometer'])->toBe(['required'])
|
|
275
305
|
->and($fuelReportRules['volume'])->toBe(['required'])
|
|
276
|
-
|
|
306
|
+
// The update request inherits these rules, so `required` made every partial
|
|
307
|
+
// update fail — PUT with just {"status": "approved"} answered "The driver
|
|
308
|
+
// field is required.", and the update action's $request->only() list does not
|
|
309
|
+
// even include `driver`. This assertion previously pinned that behaviour.
|
|
310
|
+
->and($fuelReportUpdateRules['driver'])->toBe(['sometimes'])
|
|
311
|
+
->and($fuelReportUpdateRules['odometer'])->toBe(['sometimes'])
|
|
312
|
+
->and($fuelReportUpdateRules['volume'])->toBe(['sometimes'])
|
|
277
313
|
->and($internalDriverRules['location'][1])->toBeInstanceOf(ResolvablePoint::class)
|
|
278
314
|
->and($internalDriverRules['vehicle'][1])->toBeInstanceOf(ResolvableVehicle::class)
|
|
279
315
|
->and($internalDriverRules['latitude'])->toBe(['nullable', 'required_with:longitude', 'numeric'])
|
|
@@ -621,7 +657,7 @@ namespace {
|
|
|
621
657
|
|
|
622
658
|
expect($request->authorize())->toBeTrue()
|
|
623
659
|
->and($rules['identity'])->toBe('required|string')
|
|
624
|
-
->and($rules['code'])->toBe('required|
|
|
660
|
+
->and($rules['code'])->toBe('required|string')
|
|
625
661
|
->and($rules['name'])->toBe('required|string')
|
|
626
662
|
->and($rules['password'])->toBe('required|string|min:8')
|
|
627
663
|
->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts')
|
|
@@ -649,13 +685,14 @@ namespace {
|
|
|
649
685
|
|
|
650
686
|
expect($request->authorize())->toBeFalse();
|
|
651
687
|
|
|
652
|
-
|
|
688
|
+
session(['company' => 'company-uuid']);
|
|
689
|
+
bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
|
|
653
690
|
|
|
654
691
|
$createRules = $request->rules();
|
|
655
692
|
$patchRules = CreatePartRequest::create('/fleetops-test', 'PATCH')->rules();
|
|
656
693
|
|
|
657
694
|
expect($request->authorize())->toBeTrue()
|
|
658
|
-
->and($createRules['sku'])->
|
|
695
|
+
->and(ruleStrings($createRules['sku']))->toContain('nullable', 'string', 'unique:parts,sku')
|
|
659
696
|
->and(ruleStrings($createRules['name']))->toContain('required', 'string')
|
|
660
697
|
->and(ruleStrings($patchRules['name']))->not->toContain('required')
|
|
661
698
|
->and($createRules['manufacturer'])->toBe(['nullable', 'string'])
|
|
@@ -671,11 +708,66 @@ namespace {
|
|
|
671
708
|
->and($createRules['specs'])->toBe(['nullable', 'array'])
|
|
672
709
|
->and($createRules['meta'])->toBe(['nullable', 'array']);
|
|
673
710
|
|
|
711
|
+
// The SKU uniqueness rule mirrors parts_company_uuid_active_sku_unique:
|
|
712
|
+
// scoped to the session company and ignoring soft-deleted parts, so a SKU
|
|
713
|
+
// freed by a deleted part — or held by another company — stays available.
|
|
714
|
+
// Without it a duplicate reached the driver and returned a 500.
|
|
715
|
+
$skuRule = collect($createRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
|
|
716
|
+
|
|
717
|
+
expect($skuRule)->not->toBeNull()
|
|
718
|
+
->and($skuRule->constraints)->toBe([
|
|
719
|
+
['where', 'company_uuid', 'company-uuid'],
|
|
720
|
+
['whereNull', 'deleted_at'],
|
|
721
|
+
]);
|
|
722
|
+
|
|
723
|
+
// On update the record re-sends its own SKU, so it must not collide with
|
|
724
|
+
// itself. The v1 route parameter is a public_id, not a uuid.
|
|
725
|
+
$updateRules = CreatePartRequest::create('/fleetops-test', 'PUT')
|
|
726
|
+
->setRouteResolver(fn () => new class {
|
|
727
|
+
public function parameter($key, $default = null)
|
|
728
|
+
{
|
|
729
|
+
return $key === 'id' ? 'part_abc123' : $default;
|
|
730
|
+
}
|
|
731
|
+
})
|
|
732
|
+
->rules();
|
|
733
|
+
|
|
734
|
+
$updateSkuRule = collect($updateRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
|
|
735
|
+
|
|
736
|
+
expect($updateSkuRule->constraints)->toContain(['ignore', 'part_abc123', 'public_id']);
|
|
737
|
+
|
|
674
738
|
bindFleetOpsRequestSession(['is_sanctum_token' => true]);
|
|
675
739
|
|
|
676
740
|
expect($request->authorize())->toBeTrue();
|
|
677
741
|
});
|
|
678
742
|
|
|
743
|
+
test('duplicate-key requests name the offending field and ignore themselves on update', function () {
|
|
744
|
+
session(['company' => 'company-uuid']);
|
|
745
|
+
bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
|
|
746
|
+
|
|
747
|
+
// Without these the violation surfaces as the framework's generic "has already
|
|
748
|
+
// been taken", which does not say which record collided or on what.
|
|
749
|
+
expect(CreatePartRequest::create('/fleetops-test', 'POST')->messages())
|
|
750
|
+
->toBe(['sku.unique' => 'A part with this SKU already exists.'])
|
|
751
|
+
->and(CreateFuelTransactionRequest::create('/fleetops-test', 'POST')->messages())
|
|
752
|
+
->toBe(['provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.']);
|
|
753
|
+
|
|
754
|
+
// `provider` is supplied here, so resolveProvider() short-circuits on the input
|
|
755
|
+
// and this exercises only the ignore-self clause. The stored-provider fallback
|
|
756
|
+
// needs a database and is covered in FuelProviderTransactionControllerContractsTest.
|
|
757
|
+
$updateRules = CreateFuelTransactionRequest::create('/fleetops-test', 'PUT', ['provider' => 'petroapp'])
|
|
758
|
+
->setRouteResolver(fn () => new class {
|
|
759
|
+
public function parameter($key, $default = null)
|
|
760
|
+
{
|
|
761
|
+
return $key === 'id' ? 'fuel_provider_transaction_abc123' : $default;
|
|
762
|
+
}
|
|
763
|
+
})
|
|
764
|
+
->rules();
|
|
765
|
+
|
|
766
|
+
$updateRule = collect($updateRules['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
|
|
767
|
+
|
|
768
|
+
expect($updateRule->constraints)->toContain(['ignore', 'fuel_provider_transaction_abc123', 'public_id']);
|
|
769
|
+
});
|
|
770
|
+
|
|
679
771
|
test('entity equipment payload and simulation requests expose conditional contracts', function () {
|
|
680
772
|
bindFleetOpsRequestSession();
|
|
681
773
|
|
|
@@ -227,19 +227,30 @@ class FleetOpsGettingStartedControllerProbe extends GettingStartedController
|
|
|
227
227
|
|
|
228
228
|
class FleetOpsPublicNavigatorControllerProbe extends PublicNavigatorController
|
|
229
229
|
{
|
|
230
|
-
public mixed $settings
|
|
231
|
-
public array $lookups
|
|
230
|
+
public mixed $settings = ['require_photo' => true];
|
|
231
|
+
public array $lookups = [];
|
|
232
|
+
public bool $companyMiss = false;
|
|
232
233
|
|
|
233
234
|
protected function findCompanyByPublicId(string $companyId): ?Company
|
|
234
235
|
{
|
|
236
|
+
$this->lookups[] = $companyId;
|
|
237
|
+
|
|
238
|
+
// Mirrors the real lookup returning null for an unknown public id.
|
|
239
|
+
if ($this->companyMiss) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
235
243
|
$company = new Company();
|
|
236
244
|
$company->setRawAttributes(['uuid' => 'company-uuid', 'public_id' => $companyId], true);
|
|
237
245
|
|
|
238
|
-
$this->lookups[] = $companyId;
|
|
239
|
-
|
|
240
246
|
return $company;
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
protected function errorResponse(string $message, int $statusCode): JsonResponse
|
|
250
|
+
{
|
|
251
|
+
return new JsonResponse(['errors' => [$message]], $statusCode);
|
|
252
|
+
}
|
|
253
|
+
|
|
243
254
|
protected function driverOnboardSetting(string $companyUuid): mixed
|
|
244
255
|
{
|
|
245
256
|
$this->lookups[] = $companyUuid;
|
|
@@ -412,6 +423,19 @@ test('public navigator controller returns configured or default driver onboardin
|
|
|
412
423
|
]);
|
|
413
424
|
});
|
|
414
425
|
|
|
426
|
+
test('public navigator controller answers 404 for an organization public id that does not resolve', function () {
|
|
427
|
+
$controller = new FleetOpsPublicNavigatorControllerProbe();
|
|
428
|
+
$controller->companyMiss = true;
|
|
429
|
+
|
|
430
|
+
$response = $controller->getDriverOnboardSettings('company_does_not_exist');
|
|
431
|
+
|
|
432
|
+
// Regression: the unresolved company used to be dereferenced anyway, which threw a
|
|
433
|
+
// TypeError out of the controller and rendered an HTML stack trace with a 500.
|
|
434
|
+
expect($response->getStatusCode())->toBe(404)
|
|
435
|
+
->and($response->getData(true))->toBe(['errors' => ['Organization not found.']])
|
|
436
|
+
->and($controller->lookups)->toBe(['company_does_not_exist']);
|
|
437
|
+
});
|
|
438
|
+
|
|
415
439
|
test('public contact controller normalizes create input and preserves update fields', function () {
|
|
416
440
|
$controller = new FleetOpsContactControllerProbe();
|
|
417
441
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Http\Filter\MaintenanceScheduleFilter;
|
|
4
|
+
use Fleetbase\FleetOps\Models\Vehicle;
|
|
5
|
+
use Fleetbase\Http\Filter\Filter;
|
|
6
|
+
|
|
7
|
+
class FleetOpsMaintenanceScheduleFilterQuery
|
|
8
|
+
{
|
|
9
|
+
public array $wheres = [];
|
|
10
|
+
|
|
11
|
+
public function where(...$arguments): self
|
|
12
|
+
{
|
|
13
|
+
$this->wheres[] = $arguments;
|
|
14
|
+
|
|
15
|
+
return $this;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fleetopsMaintenanceScheduleFilter(FleetOpsMaintenanceScheduleFilterQuery $builder): MaintenanceScheduleFilter
|
|
20
|
+
{
|
|
21
|
+
$filter = (new ReflectionClass(MaintenanceScheduleFilter::class))->newInstanceWithoutConstructor();
|
|
22
|
+
|
|
23
|
+
foreach ([
|
|
24
|
+
'builder' => $builder,
|
|
25
|
+
'session' => new class {
|
|
26
|
+
public function get(string $key): ?string
|
|
27
|
+
{
|
|
28
|
+
return $key === 'company' ? 'company-uuid' : null;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
] as $property => $value) {
|
|
32
|
+
$reflection = new ReflectionProperty(Filter::class, $property);
|
|
33
|
+
$reflection->setAccessible(true);
|
|
34
|
+
$reflection->setValue($filter, $value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return $filter;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('maintenance schedule filter scopes companies and resolves subject aliases', function () {
|
|
41
|
+
$builder = new FleetOpsMaintenanceScheduleFilterQuery();
|
|
42
|
+
$filter = fleetopsMaintenanceScheduleFilter($builder);
|
|
43
|
+
|
|
44
|
+
$filter->queryForInternal();
|
|
45
|
+
$filter->queryForPublic();
|
|
46
|
+
$filter->subjectType('vehicle');
|
|
47
|
+
$filter->subjectType('fleet-ops:vehicle');
|
|
48
|
+
$filter->subjectType(Vehicle::class);
|
|
49
|
+
$filter->subjectUuid('vehicle-uuid');
|
|
50
|
+
|
|
51
|
+
expect($builder->wheres)->toBe([
|
|
52
|
+
['company_uuid', 'company-uuid'],
|
|
53
|
+
['company_uuid', 'company-uuid'],
|
|
54
|
+
['subject_type', Vehicle::class],
|
|
55
|
+
['subject_type', Vehicle::class],
|
|
56
|
+
['subject_type', Vehicle::class],
|
|
57
|
+
['subject_uuid', 'vehicle-uuid'],
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('maintenance schedule filter ignores empty subject filters', function () {
|
|
62
|
+
$builder = new FleetOpsMaintenanceScheduleFilterQuery();
|
|
63
|
+
$filter = fleetopsMaintenanceScheduleFilter($builder);
|
|
64
|
+
|
|
65
|
+
$filter->subjectType(null);
|
|
66
|
+
$filter->subjectUuid(null);
|
|
67
|
+
|
|
68
|
+
expect($builder->wheres)->toBe([]);
|
|
69
|
+
});
|
|
@@ -10,6 +10,7 @@ namespace Illuminate\Foundation\Http {
|
|
|
10
10
|
|
|
11
11
|
namespace {
|
|
12
12
|
use Fleetbase\FleetOps\Http\Requests\CreateFuelReportRequest;
|
|
13
|
+
use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest;
|
|
13
14
|
|
|
14
15
|
class FleetOpsCreateFuelReportSessionStore
|
|
15
16
|
{
|
|
@@ -39,6 +40,26 @@ namespace {
|
|
|
39
40
|
return CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST');
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
test('fuel report create only fields are required on POST and optional on update', function () {
|
|
44
|
+
FleetOpsCreateFuelReportRequestState::$session = new FleetOpsCreateFuelReportSessionStore(['api_credential' => 'api-credential-uuid']);
|
|
45
|
+
|
|
46
|
+
// UpdateFuelReportRequest extends this class, so a flat `required` made every
|
|
47
|
+
// partial update fail — PUT with just {"status": "approved"} answered "The driver
|
|
48
|
+
// field is required." `driver` is the plainest case: the update action's
|
|
49
|
+
// $request->only() list does not include it, so the field was demanded and then
|
|
50
|
+
// discarded.
|
|
51
|
+
$create = CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST')->rules();
|
|
52
|
+
$update = UpdateFuelReportRequest::create('/fleet-ops/fuel-reports/report_1', 'PUT')->rules();
|
|
53
|
+
|
|
54
|
+
foreach (['driver', 'odometer', 'volume'] as $field) {
|
|
55
|
+
expect($create[$field])->toBe(['required'])
|
|
56
|
+
->and($update[$field])->toBe(['sometimes']);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
expect($create['metric_unit'])->toBe(['nullable'])
|
|
60
|
+
->and($create['amount'])->toBe(['nullable']);
|
|
61
|
+
});
|
|
62
|
+
|
|
42
63
|
test('create fuel report authorization accepts api credentials or sanctum sessions', function () {
|
|
43
64
|
expect(fleetopsCreateFuelReportRequestWithSession([])->authorize())->toBeFalse()
|
|
44
65
|
->and(fleetopsCreateFuelReportRequestWithSession(['api_credential' => 'api-credential-uuid'])->authorize())->toBeTrue()
|
|
@@ -180,4 +180,11 @@ test('navigator driver onboard settings endpoint resolves company settings', fun
|
|
|
180
180
|
$connection->table('companies')->insert(['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Beta']);
|
|
181
181
|
$empty = (new NavigatorController())->getDriverOnboardSettings('company_two');
|
|
182
182
|
expect($empty->getData(true)['driverOnboardSettings'])->toBe([]);
|
|
183
|
+
|
|
184
|
+
// An unresolved public id is a client error. This drives the real
|
|
185
|
+
// errorResponse() seam, which the probe in SmallControllerContractsTest
|
|
186
|
+
// replaces — only the status is asserted, because the harness `response()`
|
|
187
|
+
// shim envelopes errors differently from the core-api macro it stands in for.
|
|
188
|
+
$missing = (new NavigatorController())->getDriverOnboardSettings('company_missing');
|
|
189
|
+
expect($missing->getStatusCode())->toBe(404);
|
|
183
190
|
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Models\Sensor;
|
|
4
|
+
use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
|
|
5
|
+
use Illuminate\Database\ConnectionResolver;
|
|
6
|
+
use Illuminate\Database\Eloquent\Model as EloquentModel;
|
|
7
|
+
use Illuminate\Database\SQLiteConnection;
|
|
8
|
+
use Illuminate\Http\Request;
|
|
9
|
+
use Illuminate\Support\Str;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Covers Sensor::boot()'s creating hook, which defaults `last_position` to
|
|
13
|
+
* POINT(0,0).
|
|
14
|
+
*
|
|
15
|
+
* The same migration that made devices.last_position NOT NULL did it to sensors
|
|
16
|
+
* too, but only Device had a default. Creating a sensor through the API failed
|
|
17
|
+
* with "Field 'last_position' doesn't have a default value" — the Postman
|
|
18
|
+
* contract run caught it, having never been able to create a sensor at all.
|
|
19
|
+
*
|
|
20
|
+
* As with the device fixtures, the dispatcher is set up front so the model event
|
|
21
|
+
* actually fires; without it the hook is never registered and its body never runs.
|
|
22
|
+
*/
|
|
23
|
+
if (!function_exists('Fleetbase\Observers\event')) {
|
|
24
|
+
eval('namespace Fleetbase\Observers; function event($event = null, $payload = []) { return []; }');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!function_exists('Fleetbase\FleetOps\Observers\event')) {
|
|
28
|
+
eval('namespace Fleetbase\FleetOps\Observers; function event($event = null, $payload = []) { return []; }');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!function_exists('Fleetbase\Support\session')) {
|
|
32
|
+
eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!function_exists('Fleetbase\Models\session')) {
|
|
36
|
+
eval('namespace Fleetbase\Models; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } public function missing($k) { return \session($k) === null; } }; } return \session($key, $default); }');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!Str::hasMacro('humanize')) {
|
|
40
|
+
Str::macro('humanize', fn ($value) => ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value))));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fleetopsSensorDefaultBoot(): SQLiteConnection
|
|
44
|
+
{
|
|
45
|
+
$pdo = new PDO('sqlite::memory:');
|
|
46
|
+
$pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
|
|
47
|
+
$pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
|
|
48
|
+
$connection = new SQLiteConnection($pdo);
|
|
49
|
+
$resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
50
|
+
$resolver->setDefaultConnection('mysql');
|
|
51
|
+
EloquentModel::setConnectionResolver($resolver);
|
|
52
|
+
|
|
53
|
+
// The creating hook is registered against whichever dispatcher is present
|
|
54
|
+
// when the model boots, so it has to exist before Device is ever touched.
|
|
55
|
+
if (!EloquentModel::getEventDispatcher()) {
|
|
56
|
+
EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
app()->instance('db', new class($connection) {
|
|
60
|
+
public function __construct(public SQLiteConnection $c)
|
|
61
|
+
{
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public function connection($name = null): SQLiteConnection
|
|
65
|
+
{
|
|
66
|
+
return $this->c;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public function __call($method, $arguments)
|
|
70
|
+
{
|
|
71
|
+
return $this->c->{$method}(...$arguments);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
app()->instance('db.schema', $connection->getSchemaBuilder());
|
|
75
|
+
app()->instance('responsecache', new class {
|
|
76
|
+
public function __call($method, $arguments)
|
|
77
|
+
{
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
82
|
+
app()->instance('request', Request::create('/v1/sensors'));
|
|
83
|
+
|
|
84
|
+
config()->set('activitylog.enabled', false);
|
|
85
|
+
config()->set('activitylog.default_auth_driver', 'web');
|
|
86
|
+
app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
|
|
87
|
+
|
|
88
|
+
$schema = $connection->getSchemaBuilder();
|
|
89
|
+
$schema->create('sensors', function ($blueprint) {
|
|
90
|
+
$blueprint->increments('id');
|
|
91
|
+
foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'telematic_uuid', 'device_uuid', 'name', 'slug', 'type', 'status', 'online', 'unit', 'last_value', 'last_reading_at', 'min_threshold', 'max_threshold', 'location', 'last_position', 'meta', 'data', 'options', 'notes', '_key'] as $column) {
|
|
92
|
+
$blueprint->string($column)->nullable();
|
|
93
|
+
}
|
|
94
|
+
$blueprint->timestamps();
|
|
95
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
session(['company' => 'company-1']);
|
|
99
|
+
|
|
100
|
+
return $connection;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('creating a sensor without a position defaults it to the null island', function () {
|
|
104
|
+
fleetopsSensorDefaultBoot();
|
|
105
|
+
|
|
106
|
+
$sensor = Sensor::create([
|
|
107
|
+
'company_uuid' => 'company-1',
|
|
108
|
+
'name' => 'Defaulted Sensor',
|
|
109
|
+
'type' => 'temperature',
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class)
|
|
113
|
+
->and($sensor->last_position->getLat())->toBe(0.0)
|
|
114
|
+
->and($sensor->last_position->getLng())->toBe(0.0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('creating a sensor with a position leaves it untouched', function () {
|
|
118
|
+
fleetopsSensorDefaultBoot();
|
|
119
|
+
|
|
120
|
+
$sensor = Sensor::create([
|
|
121
|
+
'company_uuid' => 'company-1',
|
|
122
|
+
'name' => 'Positioned Sensor',
|
|
123
|
+
'type' => 'temperature',
|
|
124
|
+
'last_position' => new SpatialPoint(1.3, 103.8),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class)
|
|
128
|
+
->and($sensor->last_position->getLat())->toBe(1.3)
|
|
129
|
+
->and($sensor->last_position->getLng())->toBe(103.8);
|
|
130
|
+
});
|