From 6e5434ad421a80f60466b6addf6bd03904bebdda Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 4 Sep 2026 18:59:35 +0800 Subject: [PATCH 01/11] Expand public Fleet, Vehicle and Driver API contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public v1 API exposed a small subset of what these records can hold, and the gaps were silent rather than loud: a caller sending a field the controller did not copy received a 200 and a response body that looked correct while the value was discarded. Fleets - Create and update accept name, color, task, status, and the service_area, zone, vendor and parent_fleet relationships as public ids. Only name and service_area were reachable before, so a fleet hierarchy could not be built through the API at all. - parent_fleet: null clears a parent. A fleet may not be its own parent, nor sit beneath one of its own descendants; both answer 422. - Four public membership endpoints, all taking public ids and sharing one response shape: POST|DELETE /v1/fleets/{fleet}/vehicles/{vehicle} POST|DELETE /v1/fleets/{fleet}/drivers/{driver} Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot. Vehicles - The input projection covered 21 of the model's 99 fields; it now covers all 90 safe ones, with type-appropriate validation for each. - vendor, category, warranty and photo resolve from public ids. - The create-time `online` default no longer applies to updates, where it silently took a vehicle offline on any partial write. Drivers - Replaces an except() blocklist with an explicit allowlist. Anything nobody had thought to exclude — auth_token, user_uuid, company_uuid — reached Driver::create() intact, while location, heading, altitude, speed and meta were dropped on every write. - email and phone are optional. An operational record may have neither; nothing is invented to fill the gap, and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied. - Driver::$fillable held 'meta,' — a trailing comma inside the string — so meta was never mass assignable. - Driver photo upload wrote photo_uuid to users, which has no such column, so every photo uploaded through the public API was dropped. Tenant isolation - Relationship inputs are validated with company-scoped exists rules and resolved again through a company-scoped lookup. A cross-company public id is answered exactly as a missing one, so a response cannot be used to probe another organization's data. - Relationship filters resolved public ids against uuid columns and so could never match. FleetFilter::query searched a `user` relation Fleet does not have, DriverFilter::phone a `phone` relation that does not exist, and FleetFilter::zone a zone_uuid column zones does not have. - Public responses report relationships as public ids; no *_uuid column appears in a public payload. Internal console responses keep their existing shape. Validation: php scripts/pest-file-runner.php — 434 files, exit 0. composer test:lint reports 4 files, all pre-existing on origin/main and none touched here. composer test:types fails on a pre-existing 13,739-error baseline; the four new source files report zero. --- .../PublicRelationNotFoundException.php | 47 +++ .../Concerns/ResolvesFleetOpsApiResources.php | 49 ++- .../Controllers/Api/v1/DriverController.php | 119 +++--- .../Controllers/Api/v1/FleetController.php | 310 ++++++++++++++-- .../Controllers/Api/v1/VehicleController.php | 116 ++++-- .../Concerns/ResolvesPublicRelationUuids.php | 54 +++ server/src/Http/Filter/DriverFilter.php | 52 ++- server/src/Http/Filter/FleetFilter.php | 51 +-- server/src/Http/Filter/VehicleFilter.php | 45 ++- .../Concerns/ScopesPublicRelationRules.php | 48 +++ .../src/Http/Requests/CreateDriverRequest.php | 60 ++- .../src/Http/Requests/CreateFleetRequest.php | 32 +- .../Http/Requests/CreateVehicleRequest.php | 181 +++++++-- .../Concerns/ResolvesPublicRelationFields.php | 83 +++++ server/src/Http/Resources/v1/Driver.php | 20 + server/src/Http/Resources/v1/Fleet.php | 35 +- server/src/Http/Resources/v1/Vehicle.php | 31 +- server/src/Models/Driver.php | 4 +- server/src/routes.php | 8 + .../ApiDriverControllerContractsTest.php | 228 +++++++++++- .../ApiEquipmentControllerContractsTest.php | 4 +- .../tests/ApiFleetControllerContractsTest.php | 351 ++++++++++++++++-- .../tests/ApiPartControllerContractsTest.php | 4 +- .../ApiVehicleControllerContractsTest.php | 181 ++++++++- .../tests/ControllerFilterContractsTest.php | 135 ++++++- .../tests/ControllerHelperContractsTest.php | 9 +- server/tests/DriverFilterExecutionTest.php | 88 ++++- .../Api/DeviceControllerContractsTest.php | 2 +- .../Feature/Http/Api/FleetMembershipTest.php | 210 +++++++++++ .../Http/Api/FleetPublicContractTest.php | 232 ++++++++++++ ...FuelTransactionControllerContractsTest.php | 4 +- .../Api/SmallApiControllerHelpersTest.php | 8 +- .../Api/VehicleControllerTrackingTest.php | 4 +- .../Api/WorkOrderControllerContractsTest.php | 2 +- server/tests/RequestContractsTest.php | 93 ++++- 35 files changed, 2570 insertions(+), 330 deletions(-) create mode 100644 server/src/Exceptions/PublicRelationNotFoundException.php create mode 100644 server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php create mode 100644 server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php create mode 100644 server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php create mode 100644 server/tests/Feature/Http/Api/FleetMembershipTest.php create mode 100644 server/tests/Feature/Http/Api/FleetPublicContractTest.php diff --git a/server/src/Exceptions/PublicRelationNotFoundException.php b/server/src/Exceptions/PublicRelationNotFoundException.php new file mode 100644 index 000000000..bf5db4a72 --- /dev/null +++ b/server/src/Exceptions/PublicRelationNotFoundException.php @@ -0,0 +1,47 @@ +relation = $relation; + $this->identifier = $identifier; + + parent::__construct( + sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)), + 0, + $previous + ); + } + + public function getRelation(): string + { + return $this->relation; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } +} diff --git a/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php index bb801d667..0e8e0c61a 100644 --- a/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +++ b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns; +use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Device; use Fleetbase\FleetOps\Models\Driver; @@ -23,16 +24,20 @@ trait ResolvesFleetOpsApiResources { - protected function resolveUuid(string $modelClass, ?string $id): ?string + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { if (empty($id)) { return null; } - return $this->resolveModel($modelClass, $id)->uuid; + return $this->resolveModel($modelClass, $id, $companyUuid)->uuid; } - protected function resolveModel(string $modelClass, string $id): Model + /** + * @param string|null $companyUuid the company to scope the lookup to; defaults + * to the session company + */ + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model { $instance = new $modelClass(); $query = $modelClass::query()->where(function ($query) use ($id, $instance) { @@ -47,8 +52,10 @@ protected function resolveModel(string $modelClass, string $id): Model } }); - if (session('company') && $this->modelHasColumn($instance, 'company_uuid')) { - $query->where($instance->qualifyColumn('company_uuid'), session('company')); + $companyUuid = $companyUuid ?? session('company'); + + if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) { + $query->where($instance->qualifyColumn('company_uuid'), $companyUuid); } $model = $query->first(); @@ -114,17 +121,45 @@ protected function isUuidIdentifierKey(string $key): bool return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1; } - protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void + protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void { if (!$request->exists($requestKey)) { return; } $input[$column] = filled($request->input($requestKey)) - ? $this->resolveUuid($modelClass, $request->input($requestKey)) + ? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid) : null; } + /** + * Apply a set of public-ID relationship inputs in one pass. + * + * `$map` is keyed by the public request key and holds `[column, modelClass]`, + * e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is + * absent from the request is left untouched; a key sent empty clears the column. + * + * Resolution failures are rethrown as a PublicRelationNotFoundException so the + * caller can say which input was at fault rather than answering with a bare + * "not found" that names no field. + * + * @param array $map + * + * @throws PublicRelationNotFoundException + */ + protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void + { + foreach ($map as $requestKey => [$column, $modelClass]) { + try { + $this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid); + } catch (ModelNotFoundException $exception) { + $identifier = $request->input($requestKey); + + throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception); + } + } + } + protected function allowedMorphTypes(): array { return [ diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 76e22c14e..a902b4833 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -6,6 +6,8 @@ use Fleetbase\FleetOps\Events\GeofenceEntered; use Fleetbase\FleetOps\Events\GeofenceExited; use Fleetbase\FleetOps\Events\VehicleLocationChanged; +use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest; use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest; use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest; @@ -16,6 +18,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; use Fleetbase\FleetOps\Support\Utils; @@ -40,6 +43,7 @@ class DriverController extends Controller { use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + use ResolvesFleetOpsApiResources; /** * Creates a new Fleetbase Driver resource. @@ -48,12 +52,6 @@ class DriverController extends Controller */ public function create(CreateDriverRequest $request) { - // get request input - $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']); - - // Add default status - $input['status'] = $request->input('status', 'available'); - // get user details for driver $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']); @@ -65,6 +63,18 @@ public function create(CreateDriverRequest $request) return $this->apiError('Company not found.'); } + // get request input. Relationship inputs resolve against the company the + // driver is being created in, which is not always the session company — + // a request may name one explicitly. + try { + $input = $this->driverInputFromRequest($request, $company->uuid); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); + } + + // Add default status + $input['status'] = $request->input('status', 'available'); + // Apply user infos $userDetails = $this->applyUserInfoFromRequest($request, $userDetails); @@ -87,30 +97,6 @@ public function create(CreateDriverRequest $request) $input['user_uuid'] = $user->uuid; $input['company_uuid'] = $company->uuid; // Ensure correct company_uuid is set - // vehicle assignment public_id -> uuid - if ($request->has('vehicle')) { - $input['vehicle_uuid'] = $this->getUuid('vehicles', [ - 'public_id' => $request->input('vehicle'), - 'company_uuid' => $company->uuid, // Use $company->uuid instead of session - ]); - } - - // vendor assignment public_id -> uuid - if ($request->has('vendor')) { - $input['vendor_uuid'] = $this->getUuid('vendors', [ - 'public_id' => $request->input('vendor'), - 'company_uuid' => $company->uuid, // Use $company->uuid instead of session - ]); - } - - // order|alias:job assignment public_id -> uuid - if ($request->has('job')) { - $input['current_job_uuid'] = $this->getUuid('orders', [ - 'public_id' => $request->input('job'), - 'company_uuid' => $company->uuid, // Use $company->uuid instead of session - ]); - } - // set default online if (!isset($input['online'])) { $input['online'] = 0; @@ -130,7 +116,11 @@ public function create(CreateDriverRequest $request) $file = $this->resolveFile($request->input('photo'), $path); if ($file) { - $user->update(['photo_uuid' => $file->uuid]); + // `photo_uuid` is not a column on users — the avatar lives in + // `avatar_uuid`, and User guards mass assignment by fillable, so + // the old key was dropped without a word and every driver photo + // uploaded through the public API was discarded. + $user->update(['avatar_uuid' => $file->uuid]); } } @@ -164,7 +154,11 @@ public function update($id, UpdateDriverRequest $request) } // get request input - $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']); + try { + $input = $this->driverInputFromRequest($request); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); + } /* * Deliberately no `password` here. Setting one through a general update @@ -181,30 +175,6 @@ public function update($id, UpdateDriverRequest $request) $driverUser->update($userDetails); } - // vehicle assignment public_id -> uuid - if ($request->has('vehicle')) { - $input['vehicle_uuid'] = $this->getUuid('vehicles', [ - 'public_id' => $request->input('vehicle'), - 'company_uuid' => $this->sessionCompany(), - ]); - } - - // vendor assignment public_id -> uuid - if ($request->has('vendor')) { - $input['vendor_uuid'] = $this->getUuid('vendors', [ - 'public_id' => $request->input('vendor'), - 'company_uuid' => $this->sessionCompany(), - ]); - } - - // order|alias:job assignment public_id -> uuid - if ($request->has('job')) { - $input['current_job_uuid'] = $this->getUuid('orders', [ - 'public_id' => $request->input('job'), - 'company_uuid' => $this->sessionCompany(), - ]); - } - // latitude / longitude if ($request->has(['latitude', 'longitude'])) { $input['location'] = $this->pointFromCoordinates($request->only(['latitude', 'longitude'])); @@ -220,7 +190,7 @@ public function update($id, UpdateDriverRequest $request) $file = $this->resolveFile($request->input('photo'), $path); if ($file) { - $driver->user->update(['photo_uuid' => $file->uuid]); + $driver->user->update(['avatar_uuid' => $file->uuid]); } } @@ -919,6 +889,41 @@ public function simulateDrivingForOrder(Driver $driver, Order $order) return response()->json($route); } + /** + * The explicit public input allowlist for a driver. + * + * Replaces an `except()` blocklist: anything not named here — `user_uuid`, + * `company_uuid`, `auth_token`, `signup_token_used`, `public_id`, `uuid`, + * `_key`, the generated `slug`, and the raw `*_uuid` relation columns — used + * to reach `Driver::create()` intact simply because nobody had thought to + * exclude it. `location`, `heading`, `altitude`, `speed` and `meta` were on + * that blocklist and so were dropped on every write, which is why a driver's + * metadata never persisted through the public API. + * + * @throws PublicRelationNotFoundException + */ + protected function driverInputFromRequest(Request $request, ?string $companyUuid = null): array + { + $input = $request->only([ + // Identity + 'internal_id', 'drivers_license_number', 'license_expiry', + // Operational + 'country', 'currency', 'city', 'online', 'current_status', 'status', + 'location', 'heading', 'bearing', 'altitude', 'speed', + // Structured / orchestrator + 'meta', 'skills', 'max_travel_time', 'max_distance', + 'time_window_start', 'time_window_end', + ]); + + $this->applyPublicIdRelations($input, [ + 'vehicle' => ['vehicle_uuid', Vehicle::class], + 'vendor' => ['vendor_uuid', Vendor::class], + 'job' => ['current_job_uuid', Order::class], + ], $request, $companyUuid); + + return $input; + } + protected function companyFromRequest(Request $request): ?Company { return Auth::getCompanyFromRequest($request); diff --git a/server/src/Http/Controllers/Api/v1/FleetController.php b/server/src/Http/Controllers/Api/v1/FleetController.php index d174f28d7..01f313499 100644 --- a/server/src/Http/Controllers/Api/v1/FleetController.php +++ b/server/src/Http/Controllers/Api/v1/FleetController.php @@ -2,52 +2,62 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; +use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; use Fleetbase\FleetOps\Http\Requests\CreateFleetRequest; use Fleetbase\FleetOps\Http\Requests\UpdateFleetRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; use Fleetbase\FleetOps\Http\Resources\v1\Fleet as FleetResource; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Fleet; -use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\FleetOps\Models\FleetDriver; +use Fleetbase\FleetOps\Models\FleetVehicle; +use Fleetbase\FleetOps\Models\ServiceArea; +use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Zone; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\Models\File; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; class FleetController extends Controller { + use ResolvesFleetOpsApiResources; + + /** + * Relationships that are eager loaded so the public resource can report each + * assignment as a public id without issuing a query per fleet. + */ + protected const PUBLIC_RELATIONS = ['serviceArea', 'zone', 'vendor', 'parentFleet', 'photo']; + /** * Creates a new Fleetbase Fleet resource. * - * @param \Fleetbase\Http\Requests\CreateFleetRequest $request - * * @return \Fleetbase\Http\Resources\Fleet */ public function create(CreateFleetRequest $request) { - // get request input - $input = $request->only(['name']); + try { + $input = $this->fleetInputFromRequest($request); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); + } // make sure company is set $input['company_uuid'] = session('company'); - // service area assignment - if ($request->has('service_area')) { - $input['service_area_uuid'] = $this->getServiceAreaUuid('service_areas', [ - 'public_id' => $request->input('service_area'), - 'company_uuid' => session('company'), - ]); - } - // create the fleet $fleet = $this->createFleet($input); - // response the driver resource - return $this->fleetResource($fleet); + // response the fleet resource + return $this->fleetResource($this->withPublicRelations($fleet)); } /** * Updates a Fleetbase Fleet resource. * - * @param string $id - * @param \Fleetbase\Http\Requests\UpdateFleetRequest $request + * @param string $id * * @return \Fleetbase\Http\Resources\Fleet */ @@ -56,7 +66,7 @@ public function update($id, UpdateFleetRequest $request) // find for the fleet try { $fleet = $this->findFleet($id); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) { + } catch (ModelNotFoundException $exception) { return $this->jsonResponse( [ 'error' => 'Fleet resource not found.', @@ -65,22 +75,23 @@ public function update($id, UpdateFleetRequest $request) ); } - // get request input - $input = $request->only(['name']); + try { + $input = $this->fleetInputFromRequest($request); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); + } - // service area assignment - if ($request->has('service_area')) { - $input['service_area_uuid'] = $this->getServiceAreaUuid('service_areas', [ - 'public_id' => $request->input('service_area'), - 'company_uuid' => session('company'), - ]); + // a fleet may not be its own parent, nor sit beneath one of its own descendants + $hierarchyError = $this->hierarchyViolation($fleet, $input); + if ($hierarchyError) { + return $this->jsonResponse(['error' => $hierarchyError], 422); } // update the fleet $fleet->update($input); // response the fleet resource - return $this->fleetResource($fleet); + return $this->fleetResource($this->withPublicRelations($fleet)); } /** @@ -105,7 +116,7 @@ public function find($id, Request $request) // find for the fleet try { $fleet = $this->findFleet($id); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) { + } catch (ModelNotFoundException $exception) { return $this->jsonResponse( [ 'error' => 'Fleet resource not found.', @@ -115,7 +126,7 @@ public function find($id, Request $request) } // response the fleet resource - return $this->fleetResource($fleet); + return $this->fleetResource($this->withPublicRelations($fleet)); } /** @@ -128,7 +139,7 @@ public function delete($id, Request $request) // find for the driver try { $fleet = $this->findFleet($id); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) { + } catch (ModelNotFoundException $exception) { return $this->jsonResponse( [ 'error' => 'Fleet resource not found.', @@ -144,9 +155,230 @@ public function delete($id, Request $request) return $this->deletedFleetResource($fleet); } - protected function getServiceAreaUuid(string $table, array $where): ?string + /** + * Adds a vehicle to a fleet. + * + * Idempotent: assigning a vehicle that is already a member answers exactly as + * the first assignment did and creates no second pivot row. A membership that + * was previously removed is restored rather than duplicated. + * + * @return \Illuminate\Http\Response + */ + public function assignVehicle(string $id, string $vehicleId) { - return Utils::getUuid($table, $where); + try { + $fleet = $this->findFleet($id); + $vehicle = $this->findVehicle($vehicleId); + } catch (ModelNotFoundException $exception) { + return $this->jsonResponse(['error' => 'Fleet or vehicle resource not found.'], 404); + } + + $this->assignVehicleToFleet($fleet, $vehicle); + + return $this->jsonResponse($this->vehicleMembershipPayload($fleet, $vehicle, true), 200); + } + + /** + * Removes a vehicle from a fleet. + * + * Removing a membership that is not there is a successful no-op, and removing + * a membership never deletes the vehicle itself, its driver assignment, or its + * membership of any other fleet. + * + * @return \Illuminate\Http\Response + */ + public function removeVehicle(string $id, string $vehicleId) + { + try { + $fleet = $this->findFleet($id); + $vehicle = $this->findVehicle($vehicleId); + } catch (ModelNotFoundException $exception) { + return $this->jsonResponse(['error' => 'Fleet or vehicle resource not found.'], 404); + } + + $this->removeVehicleFromFleet($fleet, $vehicle); + + return $this->jsonResponse($this->vehicleMembershipPayload($fleet, $vehicle, false), 200); + } + + /** + * Adds a driver to a fleet. + * + * @return \Illuminate\Http\Response + */ + public function assignDriver(string $id, string $driverId) + { + try { + $fleet = $this->findFleet($id); + $driver = $this->findDriver($driverId); + } catch (ModelNotFoundException $exception) { + return $this->jsonResponse(['error' => 'Fleet or driver resource not found.'], 404); + } + + $this->assignDriverToFleet($fleet, $driver); + + return $this->jsonResponse($this->driverMembershipPayload($fleet, $driver, true), 200); + } + + /** + * Removes a driver from a fleet. + * + * The driver keeps its vehicle assignment and every other fleet membership. + * + * @return \Illuminate\Http\Response + */ + public function removeDriver(string $id, string $driverId) + { + try { + $fleet = $this->findFleet($id); + $driver = $this->findDriver($driverId); + } catch (ModelNotFoundException $exception) { + return $this->jsonResponse(['error' => 'Fleet or driver resource not found.'], 404); + } + + $this->removeDriverFromFleet($fleet, $driver); + + return $this->jsonResponse($this->driverMembershipPayload($fleet, $driver, false), 200); + } + + /** + * The explicit public input allowlist for a fleet. + * + * Everything outside this list is either generated (`public_id`, `slug`, + * `uuid`, `_key`), tenancy (`company_uuid`) or a raw relation column, and is + * resolved from a public id rather than accepted directly. + * + * @throws PublicRelationNotFoundException + */ + protected function fleetInputFromRequest(Request $request): array + { + $input = $request->only(['name', 'color', 'task', 'status']); + + $this->applyPublicIdRelations($input, [ + 'service_area' => ['service_area_uuid', ServiceArea::class], + 'zone' => ['zone_uuid', Zone::class], + 'vendor' => ['vendor_uuid', Vendor::class], + 'parent_fleet' => ['parent_fleet_uuid', Fleet::class], + 'photo' => ['image_uuid', File::class], + ], $request); + + return $input; + } + + /** + * Reject a parent assignment that would make the tree cyclic. + * + * Returns the error message to answer with, or null when the assignment is + * sound. Walking upward from the proposed parent is enough: a cycle exists + * exactly when this fleet is already somewhere on that chain. + */ + protected function hierarchyViolation(Fleet $fleet, array $input): ?string + { + if (!array_key_exists('parent_fleet_uuid', $input) || empty($input['parent_fleet_uuid'])) { + return null; + } + + $parentUuid = $input['parent_fleet_uuid']; + + if ($parentUuid === $fleet->uuid) { + return 'A fleet cannot be its own parent fleet.'; + } + + $seen = [$parentUuid => true]; + $ancestor = $this->parentUuidOf($parentUuid); + + while ($ancestor && !isset($seen[$ancestor])) { + if ($ancestor === $fleet->uuid) { + return 'A fleet cannot be assigned beneath one of its own subfleets.'; + } + + $seen[$ancestor] = true; + $ancestor = $this->parentUuidOf($ancestor); + } + + return null; + } + + protected function parentUuidOf(string $uuid): ?string + { + return Fleet::where('uuid', $uuid)->value('parent_fleet_uuid'); + } + + protected function withPublicRelations(Fleet $fleet): Fleet + { + $fleet->loadMissing(static::PUBLIC_RELATIONS); + + return $fleet; + } + + protected function assignVehicleToFleet(Fleet $fleet, Vehicle $vehicle): void + { + $membership = FleetVehicle::withTrashed()->firstOrNew([ + 'fleet_uuid' => $fleet->uuid, + 'vehicle_uuid' => $vehicle->uuid, + ]); + + if ($membership->trashed()) { + $membership->restore(); + + return; + } + + if (!$membership->exists) { + $membership->save(); + } + } + + protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void + { + FleetVehicle::where([ + 'fleet_uuid' => $fleet->uuid, + 'vehicle_uuid' => $vehicle->uuid, + ])->delete(); + } + + protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void + { + $membership = FleetDriver::withTrashed()->firstOrNew([ + 'fleet_uuid' => $fleet->uuid, + 'driver_uuid' => $driver->uuid, + ]); + + if ($membership->trashed()) { + $membership->restore(); + + return; + } + + if (!$membership->exists) { + $membership->save(); + } + } + + protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void + { + FleetDriver::where([ + 'fleet_uuid' => $fleet->uuid, + 'driver_uuid' => $driver->uuid, + ])->delete(); + } + + protected function vehicleMembershipPayload(Fleet $fleet, Vehicle $vehicle, bool $assigned): array + { + return [ + 'fleet' => $fleet->public_id, + 'vehicle' => $vehicle->public_id, + 'assigned' => $assigned, + ]; + } + + protected function driverMembershipPayload(Fleet $fleet, Driver $driver, bool $assigned): array + { + return [ + 'fleet' => $fleet->public_id, + 'driver' => $driver->public_id, + 'assigned' => $assigned, + ]; } protected function createFleet(array $input): Fleet @@ -159,9 +391,21 @@ protected function findFleet(string $id): Fleet return Fleet::findRecordOrFail($id); } + protected function findVehicle(string $id): Vehicle + { + return Vehicle::findRecordOrFail($id); + } + + protected function findDriver(string $id): Driver + { + return Driver::findRecordOrFail($id); + } + protected function queryFleets(Request $request) { - return Fleet::queryWithRequest($request); + return Fleet::queryWithRequest($request, function (&$query) { + $query->with(static::PUBLIC_RELATIONS); + }); } protected function fleetResource(Fleet $fleet) diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 5d5e72119..0d0b9dc02 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Events\GeofenceEntered; use Fleetbase\FleetOps\Events\GeofenceExited; use Fleetbase\FleetOps\Events\VehicleLocationChanged; +use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest; use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; @@ -12,16 +14,28 @@ use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Warranty; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\LaravelMysqlSpatial\Types\Point; +use Fleetbase\Models\Category; +use Fleetbase\Models\File; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class VehicleController extends Controller { + use ResolvesFleetOpsApiResources; + + /** + * Relationships eager loaded so the public resource can report each + * assignment as a public id without a query per vehicle. + */ + protected const PUBLIC_RELATIONS = ['vendor', 'category', 'warranty', 'driver']; + /** * Creates a new Fleetbase Vehicle resource. * @@ -32,7 +46,11 @@ class VehicleController extends Controller public function create(CreateVehicleRequest $request) { // get request input - $input = $this->vehicleInputFromRequest($request); + try { + $input = $this->vehicleInputFromRequest($request); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); + } // make sure company is set $input['company_uuid'] = session('company'); @@ -40,14 +58,6 @@ public function create(CreateVehicleRequest $request) // set default online $input = $this->withDefaultOnline($input); - // vendor assignment - if ($request->has('vendor')) { - $input['vendor_uuid'] = $this->getVendorUuid('vendors', [ - 'public_id' => $request->input('vendor'), - 'company_uuid' => session('company'), - ]); - } - // latitude / longitude $input = $this->withCoordinateLocation($input, $request); @@ -98,19 +108,12 @@ public function update($id, UpdateVehicleRequest $request) } // get request input - $input = $this->vehicleInputFromRequest($request); - - // vendor assignment - if ($request->has('vendor')) { - $input['vendor_uuid'] = $this->getVendorUuid('vendors', [ - 'public_id' => $request->input('vendor'), - 'company_uuid' => session('company'), - ]); + try { + $input = $this->vehicleInputFromRequest($request); + } catch (PublicRelationNotFoundException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 404); } - // set default online - $input = $this->withDefaultOnline($input); - // latitude / longitude $input = $this->withCoordinateLocation($input, $request); @@ -347,23 +350,75 @@ private function processVehicleGeofenceCrossings(Vehicle $vehicle, Point $newLoc } } + /** + * The explicit public input allowlist for a vehicle. + * + * Mirrors every safe business field the model and the Fleet-Ops vehicle form + * expose. Deliberately absent: `company_uuid` and the raw `*_uuid` relation + * columns (resolved from public ids below), `public_id` / `uuid` / `_key` / + * `slug` (generated), `avatar_url` when given as a uuid, `vin_data` (written + * by the VIN decoder) and `telematics` (written by telematics ingestion). + * + * @throws PublicRelationNotFoundException + */ protected function vehicleInputFromRequest(Request $request): array { - return $request->only([ - 'status', 'make', 'model', 'year', 'trim', 'type', 'plate_number', 'vin', - 'meta', 'online', 'location', 'altitude', 'heading', 'speed', + $input = $request->only([ + // Identity and description + 'internal_id', 'name', 'description', 'make', 'model', 'model_type', 'year', + 'trim', 'color', 'type', 'class', 'plate_number', 'vin', 'serial_number', + 'call_sign', 'fuel_card_number', + // Measurement and operation. + // // Odometer is fillable on the model and unrestricted by the request // rules, but was absent here — so a caller sending one received a // 200 and a response body that looked correct while the reading was // discarded. Recording mileage is the single most common write a // driver app makes against a vehicle. - 'odometer', 'odometer_unit', - 'payload_capacity', 'payload_capacity_volume', - 'payload_capacity_pallets', 'payload_capacity_parcels', + 'odometer', 'odometer_unit', 'odometer_at_purchase', 'measurement_system', + 'fuel_type', 'fuel_volume_unit', 'online', 'status', + 'location', 'altitude', 'heading', 'speed', + // Body, capacity and dimensions + 'transmission', 'body_type', 'body_sub_type', 'usage_type', 'ownership_type', + 'cargo_volume', 'passenger_volume', 'interior_volume', 'weight', 'width', + 'length', 'height', 'towing_capacity', 'payload_capacity', 'seating_capacity', + 'ground_clearance', 'bed_length', 'fuel_capacity', + // Lifecycle and financing + 'financing_status', 'loan_number_of_payments', 'loan_first_payment', 'loan_amount', + 'estimated_service_life_distance_unit', 'estimated_service_life_distance', + 'estimated_service_life_months', 'insurance_value', 'depreciation_rate', + 'current_value', 'acquisition_cost', 'currency', 'purchased_at', 'lease_expires_at', + // Regulatory and engine specifications + 'emission_standard', 'dpf_equipped', 'scr_equipped', 'gvwr', 'gcwr', + 'engine_number', 'engine_model', 'engine_make', 'engine_family', + 'engine_configuration', 'engine_displacement', 'engine_size', 'horsepower', + 'horsepower_rpm', 'torque', 'torque_rpm', 'number_of_cylinders', + 'cylinder_arrangement', + // Structured and descriptive fields + 'specs', 'details', 'notes', 'meta', + // Orchestrator + 'payload_capacity_volume', 'payload_capacity_pallets', 'payload_capacity_parcels', 'skills', 'max_tasks', 'time_window_start', 'time_window_end', 'return_to_depot', ]); + + $this->applyPublicIdRelations($input, [ + 'vendor' => ['vendor_uuid', Vendor::class], + 'category' => ['category_uuid', Category::class], + 'warranty' => ['warranty_uuid', Warranty::class], + 'photo' => ['photo_uuid', File::class], + ], $request); + + return $input; } + /** + * A vehicle that does not say otherwise starts offline. + * + * Applied on create only. Applying it on update too meant any partial write — + * a plate correction, an odometer reading — silently knocked the vehicle + * offline, because the absent key was read as "set it to false" rather than + * "leave it alone". + */ protected function withDefaultOnline(array $input): array { if (!isset($input['online'])) { @@ -394,11 +449,6 @@ protected function positionDataFromTrackingInput(float $latitude, float $longitu ]; } - protected function getVendorUuid(string $table, array $where): ?string - { - return Utils::getUuid($table, $where); - } - protected function createVehicle(array $input): Vehicle { return Vehicle::create($input); @@ -416,7 +466,9 @@ protected function findDriver(string $id): Driver protected function queryVehicles(Request $request) { - return Vehicle::queryWithRequest($request); + return Vehicle::queryWithRequest($request, function (&$query) { + $query->with(static::PUBLIC_RELATIONS); + }); } protected function vehicleResource(Vehicle $vehicle) diff --git a/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php b/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php new file mode 100644 index 000000000..b722ca450 --- /dev/null +++ b/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php @@ -0,0 +1,54 @@ + $identifiers public_id / internal_id values (uuid when internal) + * + * @return array + */ + protected function resolvePublicRelationUuids(string $modelClass, string|array $identifiers, ?bool $allowUuid = null): array + { + $identifiers = array_values(array_filter(Utils::arrayFrom($identifiers), static fn ($identifier) => filled($identifier))); + + if ($identifiers === []) { + return []; + } + + $allowUuid = $allowUuid ?? Http::isInternalRequest($this->request); + $instance = new $modelClass(); + + return $modelClass::query() + ->where('company_uuid', $this->session->get('company')) + ->where(function ($query) use ($identifiers, $instance, $allowUuid) { + $query->whereIn('public_id', $identifiers); + + if (in_array('internal_id', $instance->getFillable())) { + $query->orWhereIn('internal_id', $identifiers); + } + + if ($allowUuid) { + $query->orWhereIn('uuid', $identifiers); + } + }) + ->pluck('uuid') + ->all(); + } +} diff --git a/server/src/Http/Filter/DriverFilter.php b/server/src/Http/Filter/DriverFilter.php index d0ff600fc..91c311104 100644 --- a/server/src/Http/Filter/DriverFilter.php +++ b/server/src/Http/Filter/DriverFilter.php @@ -2,14 +2,21 @@ namespace Fleetbase\FleetOps\Http\Filter; +use Fleetbase\FleetOps\Http\Filter\Concerns\ResolvesPublicRelationUuids; +use Fleetbase\FleetOps\Models\Fleet; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; use Fleetbase\Models\Company; +use Fleetbase\Support\Http; use Illuminate\Support\Str; class DriverFilter extends Filter { + use ResolvesPublicRelationUuids; + public function queryForInternal() { $this->builder->where( @@ -65,7 +72,7 @@ public function publicId(?string $publicId) public function facilitator(string $facilitator) { - $this->builder->where('vendor_uuid', $facilitator); + $this->builder->whereIn('vendor_uuid', $this->resolvePublicRelationUuids(Vendor::class, $facilitator)); } public function vehicle(string $vehicle) @@ -76,16 +83,28 @@ public function vehicle(string $vehicle) return; } - if (Str::isUuid($vehicle)) { + // The console passes a uuid; the public API passes a public or internal id. + if (Str::isUuid($vehicle) && Http::isInternalRequest($this->request)) { $this->builder->where('vehicle_uuid', $vehicle); - } else { - $this->builder->whereHas( - 'vehicle', - function ($query) use ($vehicle) { - $query->search($vehicle); - } - ); + + return; } + + $vehicleUuids = $this->resolvePublicRelationUuids(Vehicle::class, $vehicle); + + if ($vehicleUuids !== []) { + $this->builder->whereIn('vehicle_uuid', $vehicleUuids); + + return; + } + + // Fall back to a search so a partial plate or model still narrows a list. + $this->builder->whereHas( + 'vehicle', + function ($query) use ($vehicle) { + $query->search($vehicle); + } + ); } public function driversLicenseNumber(?string $driversLicenseNumber) @@ -93,12 +112,17 @@ public function driversLicenseNumber(?string $driversLicenseNumber) $this->builder->searchWhere('drivers_license_number', $driversLicenseNumber); } + /** + * `phone` is an accessor sourced from the linked user, not a relation — the + * previous `whereHas('phone')` asked Eloquent for a relation that does not + * exist and raised a 500 for every caller of `?phone=`. + */ public function phone(string $phone) { $this->builder->whereHas( - 'phone', + 'user', function ($query) use ($phone) { - $query->search($phone); + $query->searchWhere('phone', $phone); } ); } @@ -127,10 +151,12 @@ public function vendor(string $vendor) public function fleet(string $fleet) { + $fleetUuids = $this->resolvePublicRelationUuids(Fleet::class, $fleet); + $this->builder->whereHas( 'fleets', - function ($q) use ($fleet) { - $q->where('fleet_uuid', $fleet); + function ($q) use ($fleetUuids) { + $q->whereIn('fleet_uuid', $fleetUuids); } ); } diff --git a/server/src/Http/Filter/FleetFilter.php b/server/src/Http/Filter/FleetFilter.php index 2c258373c..077fa4472 100644 --- a/server/src/Http/Filter/FleetFilter.php +++ b/server/src/Http/Filter/FleetFilter.php @@ -2,11 +2,18 @@ namespace Fleetbase\FleetOps\Http\Filter; +use Fleetbase\FleetOps\Http\Filter\Concerns\ResolvesPublicRelationUuids; +use Fleetbase\FleetOps\Models\Fleet; +use Fleetbase\FleetOps\Models\ServiceArea; +use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Zone; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; class FleetFilter extends Filter { + use ResolvesPublicRelationUuids; + public function queryForInternal() { $this->builder->where('company_uuid', $this->session->get('company'))->with(['serviceArea', 'zone']); @@ -17,15 +24,17 @@ public function queryForPublic() $this->builder->where('company_uuid', $this->session->get('company')); } + /** + * Free-text search across a fleet's own columns. + * + * Previously matched against a `user` relation, which Fleet does not have — + * every `?query=` on the fleets endpoint raised a relation-not-found error + * rather than returning results. + */ public function query(?string $searchQuery) { $this->builder->where(function ($query) use ($searchQuery) { - $query->orWhereHas( - 'user', - function ($query) use ($searchQuery) { - $query->searchWhere(['name', 'email', 'phone'], $searchQuery); - } - ); + $query->searchWhere(['name', 'task', 'public_id'], $searchQuery); }); } @@ -38,44 +47,22 @@ public function parentsOnly(bool $parentsOnly = false) public function serviceArea(?string $serviceArea) { - $this->builder->whereHas( - 'serviceArea', - function ($query) use ($serviceArea) { - $query->where('uuid', $serviceArea); - } - ); + $this->builder->whereIn('service_area_uuid', $this->resolvePublicRelationUuids(ServiceArea::class, $serviceArea)); } public function zone(?string $zone) { - $this->builder->whereHas( - 'zone', - function ($query) use ($zone) { - $query->where('zone_uuid', $zone); - } - ); + $this->builder->whereIn('zone_uuid', $this->resolvePublicRelationUuids(Zone::class, $zone)); } public function parentFleet(?string $fleet) { - $this->builder->whereHas( - 'parent_fleet', - function ($query) use ($fleet) { - $query->where('uuid', $fleet); - } - ); - - $this->builder->searchWhere('parent_fleet_uuid', $fleet); + $this->builder->whereIn('parent_fleet_uuid', $this->resolvePublicRelationUuids(Fleet::class, $fleet)); } public function vendor(?string $vendor) { - $this->builder->whereHas( - 'vendor', - function ($query) use ($vendor) { - $query->where('uuid', $vendor); - } - ); + $this->builder->whereIn('vendor_uuid', $this->resolvePublicRelationUuids(Vendor::class, $vendor)); } public function publicId(?string $publicId) diff --git a/server/src/Http/Filter/VehicleFilter.php b/server/src/Http/Filter/VehicleFilter.php index bd73bc0a8..90392933a 100644 --- a/server/src/Http/Filter/VehicleFilter.php +++ b/server/src/Http/Filter/VehicleFilter.php @@ -2,14 +2,18 @@ namespace Fleetbase\FleetOps\Http\Filter; +use Fleetbase\FleetOps\Http\Filter\Concerns\ResolvesPublicRelationUuids; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Models\Fleet; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; -use Fleetbase\Support\Http; class VehicleFilter extends Filter { + use ResolvesPublicRelationUuids; + public function queryForInternal() { $this->builder->where('company_uuid', $this->session->get('company')); @@ -30,6 +34,18 @@ public function display_name(?string $display_name) $this->builder->searchWhere(['year', 'make', 'model', 'plate_number'], $display_name); } + /** + * Match a vehicle by the identifier the operator's own system uses. + * + * `internal_id` is the column an importer keys on to decide whether a + * vehicle already exists, and it was the one identifier the filter did not + * support — so every lookup fell through to a create. + */ + public function internalId(?string $internalId) + { + $this->builder->searchWhere('internal_id', $internalId); + } + public function vin(?string $vin) { $this->builder->searchWhere('vin', $vin); @@ -68,10 +84,12 @@ public function driver(?string $driverId) return; } + $driverUuids = $this->resolvePublicRelationUuids(Driver::class, $driverId); + $this->builder->whereHas( 'driver', - function ($query) use ($driverId) { - $query->where('uuid', $driverId); + function ($query) use ($driverUuids) { + $query->whereIn('uuid', $driverUuids); } ); } @@ -82,20 +100,7 @@ public function vendor(?string $vendor) return; } - $this->builder->whereIn('vendor_uuid', Vendor::query() - ->where('company_uuid', $this->session->get('company')) - ->where(function ($query) use ($vendor) { - $query->where('public_id', $vendor); - - if (in_array('internal_id', (new Vendor())->getFillable())) { - $query->orWhere('internal_id', $vendor); - } - - if (Http::isInternalRequest($this->request)) { - $query->orWhere('uuid', $vendor); - } - }) - ->pluck('uuid')); + $this->builder->whereIn('vendor_uuid', $this->resolvePublicRelationUuids(Vendor::class, $vendor)); } public function driverUuid(?string $driverId) @@ -132,10 +137,12 @@ public function updatedAt($updatedAt) public function fleet(string $fleet) { + $fleetUuids = $this->resolvePublicRelationUuids(Fleet::class, $fleet); + $this->builder->whereHas( 'fleets', - function ($q) use ($fleet) { - $q->where('fleet_uuid', $fleet); + function ($q) use ($fleetUuids) { + $q->whereIn('fleet_uuid', $fleetUuids); } ); } diff --git a/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php b/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php new file mode 100644 index 000000000..648ace6e0 --- /dev/null +++ b/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php @@ -0,0 +1,48 @@ +where(function ($query) use ($companyUuid) { + $query->where('company_uuid', $companyUuid); + $query->whereNull('deleted_at'); + }); + } + + /** + * The full rule set for an optional public relationship input. + * + * `nullable` is deliberate: sending `null` is how a caller clears an + * existing assignment, and `exists` is skipped for a null value. + * + * @return array + */ + protected function publicRelationRules(string $table, string $column = 'public_id'): array + { + return ['nullable', 'string', $this->existsInCompany($table, $column)]; + } +} diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index da2c46642..11c789f1d 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -2,12 +2,15 @@ namespace Fleetbase\FleetOps\Http\Requests; +use Fleetbase\FleetOps\Http\Requests\Concerns\ScopesPublicRelationRules; use Fleetbase\FleetOps\Rules\ResolvablePoint; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; class CreateDriverRequest extends FleetbaseRequest { + use ScopesPublicRelationRules; + /** * Determine if the user is authorized to make this request. */ @@ -24,20 +27,59 @@ public function rules(): array $isCreating = $this->isMethod('POST'); return [ - 'name' => [Rule::requiredIf($isCreating)], - 'email' => [Rule::requiredIf($isCreating), Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'phone' => [Rule::requiredIf($isCreating), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'password' => 'nullable|string', + 'name' => [Rule::requiredIf($isCreating), 'nullable', 'string', 'max:191'], + + /* + * Email and phone are optional. + * + * An operational fleet record often has neither: a subcontracted or + * yard-only driver may have no company mailbox and no handset. The + * previous contract forced both, and the only way to satisfy it was + * to invent an address or a number — which then sits in the tenant's + * user table looking real, can be mailed to, and blocks the genuine + * value later. Both are still validated and still unique when they + * are supplied; a driver created without them simply cannot sign in + * to Navigator until credentials are added. + */ + 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'phone' => ['nullable', 'string', Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'password' => 'nullable|string', + 'timezone' => 'nullable|string|max:64', + + // Identity + 'internal_id' => 'nullable|string|max:191', + 'drivers_license_number' => 'nullable|string|max:191', + 'license_expiry' => 'nullable|date', + 'photo' => 'nullable|string', + + // Operational 'country' => 'nullable|size:2', - 'city' => 'nullable|string', - 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id', - 'license_expiry' => 'nullable|date', + 'currency' => 'nullable|string|size:3', + 'city' => 'nullable|string|max:191', + 'online' => 'nullable|boolean', + 'current_status' => 'nullable|string|max:64', 'status' => 'nullable|string|in:active,available,inactive', - 'vendor' => 'nullable|exists:vendors,public_id', - 'job' => 'nullable|exists:orders,public_id', + 'heading' => 'nullable|numeric', + 'bearing' => 'nullable|numeric', + 'altitude' => 'nullable|numeric', + 'speed' => 'nullable|numeric', 'location' => ['nullable', new ResolvablePoint()], 'latitude' => ['nullable', 'required_with:longitude'], 'longitude' => ['nullable', 'required_with:latitude'], + + // Structured / orchestrator + 'meta' => 'nullable|array', + 'skills' => 'nullable|array', + 'skills.*' => 'string', + 'max_travel_time' => 'nullable|integer|min:0', + 'max_distance' => 'nullable|integer|min:0', + 'time_window_start' => 'nullable|date_format:H:i,H:i:s', + 'time_window_end' => 'nullable|date_format:H:i,H:i:s', + + // Relationships, resolved from public ids inside the caller's company + 'vehicle' => ['nullable', 'string', 'starts_with:vehicle_', $this->existsInCompany('vehicles')], + 'vendor' => $this->publicRelationRules('vendors'), + 'job' => $this->publicRelationRules('orders'), ]; } diff --git a/server/src/Http/Requests/CreateFleetRequest.php b/server/src/Http/Requests/CreateFleetRequest.php index 2a0c6af14..eab7698ff 100644 --- a/server/src/Http/Requests/CreateFleetRequest.php +++ b/server/src/Http/Requests/CreateFleetRequest.php @@ -2,11 +2,14 @@ namespace Fleetbase\FleetOps\Http\Requests; +use Fleetbase\FleetOps\Http\Requests\Concerns\ScopesPublicRelationRules; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; class CreateFleetRequest extends FleetbaseRequest { + use ScopesPublicRelationRules; + /** * Determine if the user is authorized to make this request. */ @@ -21,8 +24,33 @@ public function authorize(): bool public function rules(): array { return [ - 'name' => [Rule::requiredIf($this->isMethod('POST'))], - 'service_area' => 'exists:service_areas,public_id', + 'name' => [Rule::requiredIf($this->isMethod('POST')), 'nullable', 'string', 'max:191'], + 'color' => 'nullable|string|max:64', + 'task' => 'nullable|string|max:191', + /* + * Fleet status is not a closed set on this model: the console, the + * spreadsheet importer and existing integrations all write free-form + * values, and `active`/`disabled`/`decommissioned` are the console's + * options rather than a schema constraint. Restricting the public API + * to a subset would leave it unable to express a state the console can. + */ + 'status' => 'nullable|string|max:64', + 'service_area' => $this->publicRelationRules('service_areas'), + 'zone' => $this->publicRelationRules('zones'), + 'vendor' => $this->publicRelationRules('vendors'), + 'parent_fleet' => $this->publicRelationRules('fleets'), + 'photo' => 'nullable|string', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'service_area' => 'service area', + 'parent_fleet' => 'parent fleet', ]; } } diff --git a/server/src/Http/Requests/CreateVehicleRequest.php b/server/src/Http/Requests/CreateVehicleRequest.php index fa49dcde2..47be75af0 100644 --- a/server/src/Http/Requests/CreateVehicleRequest.php +++ b/server/src/Http/Requests/CreateVehicleRequest.php @@ -2,12 +2,45 @@ namespace Fleetbase\FleetOps\Http\Requests; +use Fleetbase\FleetOps\Http\Requests\Concerns\ScopesPublicRelationRules; use Fleetbase\FleetOps\Rules\ResolvablePoint; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; class CreateVehicleRequest extends FleetbaseRequest { + use ScopesPublicRelationRules; + + /** + * The statuses the public API accepts for a vehicle. + * + * `active` is retained even though the model rewrites it to `available`, so + * clients written against the original contract keep working. + * + * @var array + */ + public const STATUSES = [ + 'active', + 'available', + 'in_use', + 'maintenance', + 'out_of_service', + 'reserved', + 'retired', + 'staging', + 'on_route', + 'idle', + 'cleaning', + 'awaiting_parts', + 'inspection_due', + 'inspection_failed', + 'accident', + 'compliance_hold', + 'stolen', + 'operational', + 'decommissioned', + ]; + /** * Determine if the user is authorized to make this request. */ @@ -22,40 +55,126 @@ public function authorize(): bool public function rules(): array { return [ - 'status' => [ - 'nullable', - Rule::in([ - 'active', - 'available', - 'in_use', - 'maintenance', - 'out_of_service', - 'reserved', - 'retired', - 'staging', - 'on_route', - 'idle', - 'cleaning', - 'awaiting_parts', - 'inspection_due', - 'inspection_failed', - 'accident', - 'compliance_hold', - 'stolen', - 'operational', - 'decommissioned', - ]), - ], + 'status' => ['nullable', Rule::in(static::STATUSES)], + + // Identity and description + 'internal_id' => 'nullable|string|max:191', + 'name' => 'nullable|string|max:191', + 'description' => 'nullable|string', + 'make' => 'nullable|string|max:191', + 'model' => 'nullable|string|max:191', + 'model_type' => 'nullable|string|max:191', + 'year' => 'nullable|integer|min:1900|max:2100', + 'trim' => 'nullable|string|max:191', + 'color' => 'nullable|string|max:64', + 'type' => 'nullable|string|max:191', + 'class' => 'nullable|string|max:191', + 'plate_number' => 'nullable|string|max:191', + 'vin' => 'nullable|string|max:64', + 'serial_number' => 'nullable|string|max:191', + 'call_sign' => 'nullable|string|max:191', + 'fuel_card_number' => 'nullable|string|max:191', + + // Measurement and operation. + // // Validated rather than merely accepted: the model casts odometer // to an integer, so an unchecked string would be stored as 0 — a // vehicle reporting zero miles rather than an error. - 'odometer' => 'nullable|numeric|min:0', - 'odometer_unit' => 'nullable|string|max:12', - 'vendor' => 'nullable|exists:vendors,public_id', - 'driver' => 'nullable|exists:drivers,public_id', - 'location' => ['nullable', new ResolvablePoint()], - 'latitude' => ['nullable', 'required_with:longitude'], - 'longitude' => ['nullable', 'required_with:latitude'], + 'odometer' => 'nullable|numeric|min:0', + 'odometer_unit' => 'nullable|string|max:12', + 'odometer_at_purchase' => 'nullable|numeric|min:0', + 'measurement_system' => 'nullable|string|max:32', + 'fuel_type' => 'nullable|string|max:64', + 'fuel_volume_unit' => 'nullable|string|max:12', + 'online' => 'nullable|boolean', + + // Body, capacity and dimensions + 'transmission' => 'nullable|string|max:64', + 'body_type' => 'nullable|string|max:191', + 'body_sub_type' => 'nullable|string|max:191', + 'usage_type' => 'nullable|string|max:64', + 'ownership_type' => 'nullable|string|max:64', + 'cargo_volume' => 'nullable|numeric|min:0', + 'passenger_volume' => 'nullable|numeric|min:0', + 'interior_volume' => 'nullable|numeric|min:0', + 'weight' => 'nullable|numeric|min:0', + 'width' => 'nullable|numeric|min:0', + 'length' => 'nullable|numeric|min:0', + 'height' => 'nullable|numeric|min:0', + 'towing_capacity' => 'nullable|numeric|min:0', + 'payload_capacity' => 'nullable|numeric|min:0', + 'seating_capacity' => 'nullable|integer|min:0', + 'ground_clearance' => 'nullable|numeric|min:0', + 'bed_length' => 'nullable|numeric|min:0', + 'fuel_capacity' => 'nullable|numeric|min:0', + + // Lifecycle and financing + 'financing_status' => 'nullable|string|max:64', + 'loan_number_of_payments' => 'nullable|integer|min:0', + 'loan_first_payment' => 'nullable|date', + 'loan_amount' => 'nullable|numeric|min:0', + 'estimated_service_life_distance_unit' => 'nullable|string|max:12', + 'estimated_service_life_distance' => 'nullable|integer|min:0', + 'estimated_service_life_months' => 'nullable|integer|min:0', + 'insurance_value' => 'nullable|numeric|min:0', + 'depreciation_rate' => 'nullable|numeric', + 'current_value' => 'nullable|numeric|min:0', + 'acquisition_cost' => 'nullable|numeric|min:0', + 'currency' => 'nullable|string|size:3', + 'purchased_at' => 'nullable|date', + 'lease_expires_at' => 'nullable|date', + + // Regulatory and engine specifications + 'emission_standard' => 'nullable|string|max:64', + 'dpf_equipped' => 'nullable|boolean', + 'scr_equipped' => 'nullable|boolean', + 'gvwr' => 'nullable|numeric|min:0', + 'gcwr' => 'nullable|numeric|min:0', + 'engine_number' => 'nullable|string|max:191', + 'engine_model' => 'nullable|string|max:191', + 'engine_make' => 'nullable|string|max:191', + 'engine_family' => 'nullable|string|max:191', + 'engine_configuration' => 'nullable|string|max:191', + 'engine_displacement' => 'nullable|numeric|min:0', + 'engine_size' => 'nullable|numeric|min:0', + 'horsepower' => 'nullable|numeric|min:0', + 'horsepower_rpm' => 'nullable|integer|min:0', + 'torque' => 'nullable|numeric|min:0', + 'torque_rpm' => 'nullable|integer|min:0', + 'number_of_cylinders' => 'nullable|integer|min:0', + 'cylinder_arrangement' => 'nullable|string|max:64', + + // Structured and descriptive fields + 'specs' => 'nullable|array', + 'details' => 'nullable|array', + 'notes' => 'nullable|string', + 'meta' => 'nullable|array', + + // Orchestrator + 'skills' => 'nullable|array', + 'skills.*' => 'string', + 'payload_capacity_volume' => 'nullable|numeric|min:0', + 'payload_capacity_pallets' => 'nullable|integer|min:0', + 'payload_capacity_parcels' => 'nullable|integer|min:0', + 'max_tasks' => 'nullable|integer|min:0', + 'time_window_start' => 'nullable|date_format:H:i,H:i:s', + 'time_window_end' => 'nullable|date_format:H:i,H:i:s', + 'return_to_depot' => 'nullable|boolean', + + // Relationships, resolved from public ids inside the caller's company + 'vendor' => $this->publicRelationRules('vendors'), + 'driver' => $this->publicRelationRules('drivers'), + 'category' => $this->publicRelationRules('categories'), + 'warranty' => $this->publicRelationRules('warranties'), + 'photo' => 'nullable|string', + + // Location + 'location' => ['nullable', new ResolvablePoint()], + 'latitude' => ['nullable', 'required_with:longitude'], + 'longitude' => ['nullable', 'required_with:latitude'], + 'altitude' => 'nullable|numeric', + 'heading' => 'nullable|numeric', + 'speed' => 'nullable|numeric', ]; } } diff --git a/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php new file mode 100644 index 000000000..b59453064 --- /dev/null +++ b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php @@ -0,0 +1,83 @@ + + */ + protected function requestedRelations($request): array + { + $with = $request->input('with'); + + if (!is_array($with)) { + return []; + } + + return array_map(static fn ($relation) => Str::camel($relation), $with); + } + + /** + * @param string|null $foreignKey the column holding the relation, when the + * relation is a belongsTo; null for the + * inverse side, which has no local column + * @param array $with relations the caller asked for + */ + protected function publicRelationField(string $relation, ?string $foreignKey, array $with, \Closure $resource): mixed + { + if (Http::isInternalRequest()) { + return $this->whenLoaded($relation, $resource); + } + + if (in_array($relation, $with, true)) { + if (is_object($this->resource) && method_exists($this->resource, 'loadMissing')) { + $this->loadMissing($relation); + } + + return $this->{$relation} ? $resource() : null; + } + + return $this->publicIdForRelation($relation, $foreignKey); + } + + /** + * The public id behind a relationship, or null when nothing is assigned. + */ + protected function publicIdForRelation(string $relation, ?string $foreignKey): ?string + { + if ($foreignKey !== null && empty($this->{$foreignKey})) { + return null; + } + + $resource = $this->resource; + + if (is_object($resource) && method_exists($resource, 'relationLoaded') && $resource->relationLoaded($relation)) { + return data_get($this, $relation . '.public_id'); + } + + // Not every resource is wrapped around an Eloquent model — webhook + // payload fixtures and compact serializers pass plain objects — so read + // the already-materialised value rather than calling a relation method + // that is not there. + if (!is_object($resource) || !method_exists($resource, $relation)) { + return data_get($this, $relation . '.public_id'); + } + + return $this->{$relation}()->value('public_id'); + } +} diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 2e8fc4cda..dec93eb9f 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -37,6 +37,7 @@ public function toArray($request) 'name' => $this->name, 'email' => $this->email, 'phone' => $this->phone, + 'timezone' => data_get($this, 'user.timezone'), 'drivers_license_number' => $this->drivers_license_number, 'license_expiry' => $this->formatDateOnly($this->license_expiry), 'photo_url' => $this->photo_url, @@ -52,18 +53,31 @@ public function toArray($request) 'current_job_id' => $this->when(Http::isInternalRequest(), data_get($this, 'currentJob.tracking')), 'jobs' => $this->whenLoaded('jobs', fn () => $this->getJobs()), 'vendor' => $this->whenLoaded('vendor', fn () => new Vendor($this->vendor)), + // Public callers receive each assignment as a public id so a write + // can be read back; the nested objects above are unchanged. + 'vehicle_id' => $this->when(Http::isPublicRequest(), fn () => $this->vehicle_id), + 'vendor_id' => $this->when(Http::isPublicRequest(), fn () => $this->vendor_id), + 'job_id' => $this->when(Http::isPublicRequest(), fn () => data_get($this, 'currentJob.public_id')), 'fleets' => $this->whenLoaded('fleets', fn () => Fleet::collection($this->fleets()->without('drivers')->get())), 'current_shift' => $this->whenLoaded('currentShift', fn () => $this->currentShift), 'location' => $this->wasRecentlyCreated ? new Point(0, 0) : Utils::castPoint($this->location), 'heading' => (int) data_get($this, 'heading', 0), + 'bearing' => data_get($this, 'bearing'), 'altitude' => (int) data_get($this, 'altitude', 0), 'speed' => (int) data_get($this, 'speed', 0), 'country' => data_get($this, 'country'), 'currency' => data_get($this, 'currency', Utils::getCurrenyFromCountryCode($this->country)), 'city' => data_get($this, 'city', Utils::getCapitalCityFromCountryCode($this->country)), 'online' => data_get($this, 'online', false), + 'current_status' => data_get($this, 'current_status'), 'status' => $this->status, 'token' => $this->token, + // Orchestrator constraints + 'skills' => data_get($this, 'skills'), + 'max_travel_time' => $this->max_travel_time, + 'max_distance' => $this->max_distance, + 'time_window_start' => $this->time_window_start, + 'time_window_end' => $this->time_window_end, 'meta' => data_get($this, 'meta', Utils::createObject()), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, @@ -131,7 +145,13 @@ public function toWebhookPayload() 'currency' => data_get($this, 'currency', Utils::getCurrenyFromCountryCode($this->country)), 'city' => data_get($this, 'city', Utils::getCapitalCityFromCountryCode($this->country)), 'online' => data_get($this, 'online', false), + 'current_status' => data_get($this, 'current_status'), 'status' => $this->status, + 'skills' => data_get($this, 'skills'), + 'max_travel_time' => $this->max_travel_time, + 'max_distance' => $this->max_distance, + 'time_window_start' => $this->time_window_start, + 'time_window_end' => $this->time_window_end, 'meta' => data_get($this, 'meta', Utils::createObject()), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, diff --git a/server/src/Http/Resources/v1/Fleet.php b/server/src/Http/Resources/v1/Fleet.php index ab9378cb8..ee3f0b885 100644 --- a/server/src/Http/Resources/v1/Fleet.php +++ b/server/src/Http/Resources/v1/Fleet.php @@ -2,12 +2,14 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Http\Resources\v1\Concerns\ResolvesPublicRelationFields; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; -use Illuminate\Support\Str; class Fleet extends FleetbaseResource { + use ResolvesPublicRelationFields; + /** * Transform the resource into an array. * @@ -17,11 +19,9 @@ class Fleet extends FleetbaseResource */ public function toArray($request) { - if ($request->isArray('with')) { - $with = array_map(function ($relation) { - return Str::camel($relation); - }, $request->array('with')); + $with = $this->requestedRelations($request); + if ($with !== []) { $this->load($with); if (in_array('subfleets', $with, true)) { @@ -40,16 +40,21 @@ public function toArray($request) 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'name' => $this->name, + 'color' => $this->color ?? null, 'task' => $this->task ?? null, 'status' => $this->status ?? null, - 'drivers_count' => $this->when(Http::isInternalRequest(), $this->drivers_count), - 'drivers_online_count' => $this->when(Http::isInternalRequest(), $this->drivers_online_count), - 'vehicles_count' => $this->when(Http::isInternalRequest(), $this->vehicles_count), - 'vehicles_online_count' => $this->when(Http::isInternalRequest(), $this->vehicles_online_count), - 'service_area' => $this->whenLoaded('serviceArea', fn () => new ServiceArea($this->serviceArea)), - 'zone' => $this->whenLoaded('zone', fn () => new Zone($this->zone)), - 'vendor' => $this->whenLoaded('vendor', fn () => new Vendor($this->vendor)), - 'parent_fleet' => $this->whenLoaded('parentFleet', fn () => new ParentFleet($this->parentFleet)), + 'photo_url' => $this->photo_url, + // Closures, not values: `when()` evaluates a plain second argument + // eagerly, so each of these counts ran a query on every public + // request that then discarded the result. + 'drivers_count' => $this->when(Http::isInternalRequest(), fn () => $this->drivers_count), + 'drivers_online_count' => $this->when(Http::isInternalRequest(), fn () => $this->drivers_online_count), + 'vehicles_count' => $this->when(Http::isInternalRequest(), fn () => $this->vehicles_count), + 'vehicles_online_count' => $this->when(Http::isInternalRequest(), fn () => $this->vehicles_online_count), + 'service_area' => $this->publicRelationField('serviceArea', 'service_area_uuid', $with, fn () => new ServiceArea($this->serviceArea)), + 'zone' => $this->publicRelationField('zone', 'zone_uuid', $with, fn () => new Zone($this->zone)), + 'vendor' => $this->publicRelationField('vendor', 'vendor_uuid', $with, fn () => new Vendor($this->vendor)), + 'parent_fleet' => $this->publicRelationField('parentFleet', 'parent_fleet_uuid', $with, fn () => new ParentFleet($this->parentFleet)), 'subfleets' => $this->whenLoaded('subFleets', fn () => SubFleet::collection($this->subFleets)), 'drivers' => $this->whenLoaded('drivers', fn () => Driver::collection($this->drivers()->with(Http::isInternalRequest() || $request->has('with.jobs') ? ['jobs'] : [])->get())), 'vehicles' => $this->whenLoaded('vehicles', fn () => Vehicle::collection($this->vehicles)), @@ -68,11 +73,13 @@ public function toWebhookPayload() return [ 'id' => $this->public_id, 'name' => $this->name, + 'color' => $this->color ?? null, 'task' => $this->task ?? null, 'status' => $this->status ?? null, - 'parent_fleet' => $this->when($this->serviceArea, data_get($this, 'parentFleet.public_id')), + 'parent_fleet' => $this->when($this->parentFleet, data_get($this, 'parentFleet.public_id')), 'service_area' => $this->when($this->serviceArea, data_get($this, 'serviceArea.public_id')), 'zone' => $this->when($this->zone, data_get($this, 'zone.public_id')), + 'vendor' => $this->when($this->vendor, data_get($this, 'vendor.public_id')), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, ]; diff --git a/server/src/Http/Resources/v1/Vehicle.php b/server/src/Http/Resources/v1/Vehicle.php index b7d952b09..f37f38c5a 100644 --- a/server/src/Http/Resources/v1/Vehicle.php +++ b/server/src/Http/Resources/v1/Vehicle.php @@ -2,12 +2,15 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Http\Resources\v1\Concerns\ResolvesPublicRelationFields; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; class Vehicle extends FleetbaseResource { + use ResolvesPublicRelationFields; + /** * Transform the resource into an array. * @@ -17,6 +20,8 @@ class Vehicle extends FleetbaseResource */ public function toArray($request) { + $with = $this->requestedRelations($request); + return $this->withCustomFields([ // Identity 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), @@ -41,8 +46,12 @@ public function toArray($request) 'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name), 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()), 'current_order_reference'=> $this->when(Http::isInternalRequest(), $this->currentOrderReference()), - // Relationships - 'driver' => $this->whenLoaded('driver', fn () => new Driver($this->driver)), + // Relationships. A public caller receives the assignment as a public id + // so a write can be read back; `?with=driver` still returns the object. + 'driver' => $this->publicRelationField('driver', null, $with, fn () => new Driver($this->driver)), + 'vendor' => $this->publicRelationField('vendor', 'vendor_uuid', $with, fn () => new Vendor($this->vendor)), + 'category' => $this->when(Http::isPublicRequest(), fn () => $this->publicIdForRelation('category', 'category_uuid')), + 'warranty' => $this->when(Http::isPublicRequest(), fn () => $this->publicIdForRelation('warranty', 'warranty_uuid')), 'devices' => $this->whenLoaded('devices', fn () => $this->devices), // Vehicle identification 'make' => $this->make, @@ -139,6 +148,15 @@ public function toArray($request) 'altitude' => (int) data_get($this, 'altitude', 0), 'speed' => (int) data_get($this, 'speed', 0), 'telematics' => data_get($this, 'telematics'), + // Orchestrator constraints + 'skills' => data_get($this, 'skills'), + 'payload_capacity_volume' => $this->payload_capacity_volume, + 'payload_capacity_pallets' => $this->payload_capacity_pallets, + 'payload_capacity_parcels' => $this->payload_capacity_parcels, + 'max_tasks' => $this->max_tasks, + 'time_window_start' => $this->time_window_start, + 'time_window_end' => $this->time_window_end, + 'return_to_depot' => $this->return_to_depot, // Notes & meta 'notes' => $this->notes, 'meta' => data_get($this, 'meta', Utils::createObject()), @@ -273,6 +291,15 @@ public function toWebhookPayload() 'vin_data' => data_get($this, 'vin_data', Utils::createObject()), 'specs' => data_get($this, 'specs', Utils::createObject()), 'details' => data_get($this, 'details', Utils::createObject()), + // Orchestrator constraints + 'skills' => data_get($this, 'skills'), + 'payload_capacity_volume' => $this->payload_capacity_volume, + 'payload_capacity_pallets' => $this->payload_capacity_pallets, + 'payload_capacity_parcels' => $this->payload_capacity_parcels, + 'max_tasks' => $this->max_tasks, + 'time_window_start' => $this->time_window_start, + 'time_window_end' => $this->time_window_end, + 'return_to_depot' => $this->return_to_depot, // Notes & meta 'notes' => $this->notes, 'meta' => data_get($this, 'meta', Utils::createObject()), diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 378fcb8e4..142b0d45b 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -110,7 +110,7 @@ class Driver extends Model 'current_status', 'slug', 'status', - 'meta,', + 'meta', // Orchestrator 'skills', 'max_travel_time', @@ -255,7 +255,7 @@ public function setLicenseExpiryAttribute($value): void */ public function user() { - return $this->belongsTo(User::class)->select(['uuid', 'company_uuid', 'public_id', 'avatar_uuid', 'name', 'phone', 'email', 'type', 'status', 'last_login'])->without(['driver'])->withTrashed(); + return $this->belongsTo(User::class)->select(['uuid', 'company_uuid', 'public_id', 'avatar_uuid', 'name', 'phone', 'email', 'timezone', 'type', 'status', 'last_login'])->without(['driver'])->withTrashed(); } /** diff --git a/server/src/routes.php b/server/src/routes.php index 2e0a4c038..1e3cf607d 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -302,6 +302,14 @@ function ($router) { $router->group(['prefix' => 'fleets'], function () use ($router) { $router->post('/', 'FleetController@create'); $router->get('/', 'FleetController@query'); + // Membership routes are declared ahead of the `{id}` routes so a + // literal `vehicles`/`drivers` segment can never be swallowed by + // the single-parameter patterns below. Both parameters are public + // ids; no internal uuid is ever accepted or returned here. + $router->post('{id}/vehicles/{vehicle}', 'FleetController@assignVehicle'); + $router->delete('{id}/vehicles/{vehicle}', 'FleetController@removeVehicle'); + $router->post('{id}/drivers/{driver}', 'FleetController@assignDriver'); + $router->delete('{id}/drivers/{driver}', 'FleetController@removeDriver'); $router->get('{id}', 'FleetController@find'); $router->put('{id}', 'FleetController@update'); $router->delete('{id}', 'FleetController@delete'); diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index 137831504..4e49ab04f 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -4,7 +4,9 @@ use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest; use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest; use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; use Fleetbase\Models\User; @@ -29,6 +31,9 @@ class FleetOpsApiDriverControllerProbe extends DriverController public array $companyCalls = []; public array $createdUsers = []; public array $uuidLookups = []; + public array $relationLookups = []; + public array $relationCompanyScopes = []; + public array $unresolvable = []; public array $pointInputs = []; public array $createdDrivers = []; public array $resolvedFiles = []; @@ -76,6 +81,33 @@ protected function getUuid(array|string $table, array $where, array $options = [ return $table . '-uuid'; } + /** + * Stand in for the company-scoped public-id lookup. + * + * Anything listed in `$unresolvable` behaves as a cross-company or missing + * identifier does in production. + */ + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string + { + if (empty($id)) { + return null; + } + + $this->relationLookups[] = [$modelClass, $id]; + $this->relationCompanyScopes[] = $companyUuid; + + if (in_array($id, $this->unresolvable, true)) { + throw (new ModelNotFoundException())->setModel($modelClass, $id); + } + + return strtolower(class_basename($modelClass)) . '-uuid'; + } + + public function inputForTest(Request $request): array + { + return $this->driverInputFromRequest($request); + } + protected function pointFromCoordinates(array $coordinates): Point { $this->pointInputs[] = $coordinates; @@ -343,21 +375,23 @@ public function or(array $keys, mixed $default = null): mixed ->and($controller->user->assignedRoles)->toBe(['Driver']) ->and($controller->createdDrivers[0])->toMatchArray([ 'status' => 'available', - 'vehicle_uuid' => 'vehicles-uuid', - 'vendor_uuid' => 'vendors-uuid', - 'current_job_uuid' => 'orders-uuid', + 'vehicle_uuid' => 'vehicle-uuid', + 'vendor_uuid' => 'vendor-uuid', + 'current_job_uuid' => 'order-uuid', 'online' => 0, 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid', ]) ->and($controller->createdDrivers[0]['location'])->toBeInstanceOf(Point::class) - ->and($controller->uuidLookups)->toContain( - ['vehicles', ['public_id' => 'vehicle_public', 'company_uuid' => 'company-uuid'], []], - ['vendors', ['public_id' => 'vendor_public', 'company_uuid' => 'company-uuid'], []], - ['orders', ['public_id' => 'order_public', 'company_uuid' => 'company-uuid'], []] - ) + ->and($controller->relationLookups)->toBe([ + [Vehicle::class, 'vehicle_public'], + [Vendor::class, 'vendor_public'], + [Order::class, 'order_public'], + ]) ->and($controller->resolvedFiles)->toBe([['file_public', 'uploads/company-uuid/drivers']]) - ->and($controller->user->updates)->toContain(['photo_uuid' => 'photo-file-uuid']) + // `photo_uuid` is not a column on users; the avatar lives in `avatar_uuid` + // and the old key was silently dropped by mass assignment. + ->and($controller->user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid']) ->and($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']); }); @@ -411,14 +445,14 @@ public function or(array $keys, mixed $default = null): mixed ]) ->and($driver->updates[0])->toMatchArray([ 'status' => 'busy', - 'vehicle_uuid' => 'vehicles-uuid', - 'vendor_uuid' => 'vendors-uuid', - 'current_job_uuid' => 'orders-uuid', + 'vehicle_uuid' => 'vehicle-uuid', + 'vendor_uuid' => 'vendor-uuid', + 'current_job_uuid' => 'order-uuid', ]) ->and($driver->updates[0]['location'])->toBeInstanceOf(Point::class) ->and($driver->flushedForTest)->toBeTrue() ->and($controller->resolvedFiles)->toBe([['photo-input', 'uploads/session-company-uuid/drivers']]) - ->and($user->updates)->toContain(['photo_uuid' => 'photo-file-uuid']) + ->and($user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid']) ->and($driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']); }); @@ -634,3 +668,171 @@ public function or(array $keys, mixed $default = null): mixed ->and($user->updates[0])->not->toHaveKey('password') ->and($user->updates[0])->toHaveKey('name'); }); + +test('api driver controller accepts every safe driver field the model exposes', function () { + $payload = [ + // Identity + 'internal_id' => 'DRV-9001', 'drivers_license_number' => 'S1234567A', + 'license_expiry' => '2030-06-30', + // Operational + 'country' => 'SG', 'currency' => 'SGD', 'city' => 'Singapore', 'online' => true, + 'current_status' => 'on_break', 'status' => 'available', + 'heading' => 180, 'bearing' => 90, 'altitude' => 15, 'speed' => 42, + // Structured / orchestrator + 'meta' => ['depot' => 'north'], 'skills' => ['hazmat'], + 'max_travel_time' => 28800, 'max_distance' => 250000, + 'time_window_start' => '08:00', 'time_window_end' => '18:00', + ]; + + $controller = new FleetOpsApiDriverControllerProbe(); + $input = $controller->inputForTest(new Request($payload)); + + $missing = array_values(array_diff(array_keys($payload), array_keys($input))); + + expect($missing)->toBe([]) + ->and($input)->toMatchArray($payload); +}); + +test('api driver controller input excludes authentication tenancy and generated columns', function () { + // The projection used to be an `except()` blocklist, so anything nobody had + // thought to name reached Driver::create() intact — including the auth token. + $controller = new FleetOpsApiDriverControllerProbe(); + + $input = $controller->inputForTest(new Request([ + 'internal_id' => 'DRV-1', + 'auth_token' => 'forged', + 'signup_token_used' => true, + 'user_uuid' => 'forged', + 'company_uuid' => 'someone-elses-company', + 'vehicle_uuid' => 'forged', + 'vendor_uuid' => 'forged', + 'current_job_uuid' => 'forged', + '_key' => 'forged', + 'uuid' => 'forged', + 'public_id' => 'driver_forged', + 'slug' => 'forged', + 'avatar_url' => 'forged', + ])); + + expect(array_keys($input))->toBe(['internal_id']); +}); + +test('api driver controller persists meta location and telemetry that the blocklist used to drop', function () { + // `location`, `heading`, `altitude`, `speed` and `meta` were all on the old + // `except()` list, so every write silently discarded them — and `meta` could + // not be mass assigned anyway because the model spelled it `meta,`. + $controller = new FleetOpsApiDriverControllerProbe(); + + $input = $controller->inputForTest(new Request([ + 'meta' => ['badge' => 'A12'], + 'heading' => 270, + 'altitude' => 8, + 'speed' => 55, + ])); + + expect($input)->toMatchArray([ + 'meta' => ['badge' => 'A12'], + 'heading' => 270, + 'altitude' => 8, + 'speed' => 55, + ])->and(in_array('meta', (new Driver())->getFillable(), true))->toBeTrue(); +}); + +test('api driver controller creates an operational driver with no email or phone', function () { + $controller = new FleetOpsApiDriverControllerProbe(); + + $response = $controller->create(new CreateDriverRequest([ + 'name' => 'Yard Driver', + 'internal_id' => 'DRV-7788', + ])); + + // No invented address, no invented number: the user record simply carries + // neither, which the users table permits. The driver cannot sign in to + // Navigator until credentials are supplied. + expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver]) + ->and($controller->createdUsers[0])->toMatchArray(['name' => 'Yard Driver']) + ->and($controller->createdUsers[0]['email'] ?? null)->toBeNull() + ->and($controller->createdUsers[0]['phone'] ?? null)->toBeNull() + ->and($controller->createdDrivers[0])->toMatchArray([ + 'internal_id' => 'DRV-7788', + 'user_uuid' => 'user-uuid', + 'company_uuid' => 'company-uuid', + 'status' => 'available', + ]) + // The Driver-to-User relationship, organization membership, user type + // and role are all preserved for a credential-less driver. + ->and($controller->user->assignedCompanies)->toBe(['company-uuid']) + ->and($controller->user->assignedTypes)->toBe(['driver']) + ->and($controller->user->assignedRoles)->toBe(['Driver']); +}); + +test('api driver controller creates a driver with only one contact method', function () { + $emailOnly = new FleetOpsApiDriverControllerProbe(); + $emailOnly->create(new CreateDriverRequest([ + 'name' => 'Email Only', + 'email' => 'email.only@example.test', + ])); + + $phoneOnly = new FleetOpsApiDriverControllerProbe(); + $phoneOnly->create(new CreateDriverRequest([ + 'name' => 'Phone Only', + 'phone' => '+15550001111', + ])); + + expect($emailOnly->createdUsers[0])->toMatchArray(['email' => 'email.only@example.test']) + ->and($emailOnly->createdUsers[0]['phone'] ?? null)->toBeNull() + ->and($phoneOnly->createdUsers[0])->toMatchArray(['phone' => '+15550001111']) + ->and($phoneOnly->createdUsers[0]['email'] ?? null)->toBeNull(); +}); + +test('api driver controller rejects a relationship that belongs to another company', function () { + $controller = new FleetOpsApiDriverControllerProbe(); + $controller->unresolvable = ['vehicle_other_company']; + + $created = $controller->create(new CreateDriverRequest([ + 'name' => 'Driver One', + 'vehicle' => 'vehicle_other_company', + ])); + + $controller = new FleetOpsApiDriverControllerProbe(); + $controller->unresolvable = ['vendor_other_company']; + + $updated = $controller->update('driver_public', new UpdateDriverRequest([ + 'vendor' => 'vendor_other_company', + ])); + + expect($created)->toBe([ + 'json' => ['error' => 'No vehicle resource found for the identifier provided.'], + 'status' => 404, + ])->and($updated)->toBe([ + 'json' => ['error' => 'No vendor resource found for the identifier provided.'], + 'status' => 404, + ]); +}); + +test('api driver controller clears a relationship when the input is sent empty', function () { + $controller = new FleetOpsApiDriverControllerProbe(); + + $input = $controller->inputForTest(new Request(['vehicle' => null, 'vendor' => '', 'job' => null])); + + expect($input)->toMatchArray([ + 'vehicle_uuid' => null, + 'vendor_uuid' => null, + 'current_job_uuid' => null, + ])->and($controller->relationLookups)->toBe([]); +}); + +test('api driver controller resolves driver relationships inside the company the driver is created in', function () { + // A create request may name a company explicitly, and the relationships must + // be looked up in that company rather than in whatever the session holds. + $controller = new FleetOpsApiDriverControllerProbe(); + + $controller->create(new CreateDriverRequest([ + 'company' => 'company_public', + 'name' => 'Driver One', + 'vehicle' => 'vehicle_public', + 'vendor' => 'vendor_public', + ])); + + expect($controller->relationCompanyScopes)->toBe(['company-uuid', 'company-uuid']); +}); diff --git a/server/tests/ApiEquipmentControllerContractsTest.php b/server/tests/ApiEquipmentControllerContractsTest.php index 0d8b2bc11..c5daec6b1 100644 --- a/server/tests/ApiEquipmentControllerContractsTest.php +++ b/server/tests/ApiEquipmentControllerContractsTest.php @@ -26,7 +26,7 @@ protected function createEquipment(array $input): Equipment return $equipment; } - protected function resolveModel(string $modelClass, string $id): Illuminate\Database\Eloquent\Model + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Illuminate\Database\Eloquent\Model { if ($this->equipmentNotFound) { throw new ModelNotFoundException(); @@ -37,7 +37,7 @@ protected function resolveModel(string $modelClass, string $id): Illuminate\Data return $this->equipment; } - protected function resolveUuid(string $modelClass, ?string $id): ?string + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { $this->resolvedUuids[] = [$modelClass, $id]; diff --git a/server/tests/ApiFleetControllerContractsTest.php b/server/tests/ApiFleetControllerContractsTest.php index cc0dded72..e9fdc8e1d 100644 --- a/server/tests/ApiFleetControllerContractsTest.php +++ b/server/tests/ApiFleetControllerContractsTest.php @@ -3,23 +3,60 @@ use Fleetbase\FleetOps\Http\Controllers\Api\v1\FleetController; use Fleetbase\FleetOps\Http\Requests\CreateFleetRequest; use Fleetbase\FleetOps\Http\Requests\UpdateFleetRequest; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Fleet; +use Fleetbase\FleetOps\Models\ServiceArea; +use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Zone; +use Fleetbase\Models\File; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; class FleetOpsApiFleetControllerProbe extends FleetController { - public ?Fleet $fleet = null; - public array $createdFleets = []; - public array $serviceAreaLookups = []; - public mixed $queryResults = null; - public bool $fleetNotFound = false; + public ?Fleet $fleet = null; + public ?Vehicle $vehicle = null; + public ?Driver $driver = null; + public array $createdFleets = []; + public array $relationLookups = []; + public array $unresolvable = []; + public array $parentChain = []; + public array $membershipCalls = []; + public mixed $queryResults = null; + public bool $fleetNotFound = false; + public bool $resourceNotFound = false; + + public function inputForTest(Request $request): array + { + return $this->fleetInputFromRequest($request); + } - protected function getServiceAreaUuid(string $table, array $where): ?string + /** + * Stand in for the company-scoped public-id lookup. + * + * Anything listed in `$unresolvable` behaves as a cross-company or missing + * identifier does in production: nothing is found inside the caller's own + * company, and the lookup raises. + */ + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { - $this->serviceAreaLookups[] = [$table, $where]; + if (empty($id)) { + return null; + } + + $this->relationLookups[] = [$modelClass, $id]; + + if (in_array($id, $this->unresolvable, true)) { + throw (new ModelNotFoundException())->setModel($modelClass, $id); + } + + return $id . '-uuid'; + } - return 'service-area-uuid'; + protected function parentUuidOf(string $uuid): ?string + { + return $this->parentChain[$uuid] ?? null; } protected function createFleet(array $input): Fleet @@ -27,7 +64,7 @@ protected function createFleet(array $input): Fleet $this->createdFleets[] = $input; $fleet = new FleetOpsApiFleetFake(); - $fleet->setRawAttributes(array_merge(['uuid' => 'created-fleet-uuid'], $input)); + $fleet->setRawAttributes(array_merge(['uuid' => 'created-fleet-uuid', 'public_id' => 'fleet_created'], $input)); return $fleet; } @@ -43,6 +80,49 @@ protected function findFleet(string $id): Fleet return $this->fleet; } + protected function findVehicle(string $id): Vehicle + { + if ($this->resourceNotFound) { + throw new ModelNotFoundException(); + } + + return $this->vehicle; + } + + protected function findDriver(string $id): Driver + { + if ($this->resourceNotFound) { + throw new ModelNotFoundException(); + } + + return $this->driver; + } + + protected function withPublicRelations(Fleet $fleet): Fleet + { + return $fleet; + } + + protected function assignVehicleToFleet(Fleet $fleet, Vehicle $vehicle): void + { + $this->membershipCalls[] = ['assign-vehicle', $fleet->public_id, $vehicle->public_id]; + } + + protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void + { + $this->membershipCalls[] = ['remove-vehicle', $fleet->public_id, $vehicle->public_id]; + } + + protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void + { + $this->membershipCalls[] = ['assign-driver', $fleet->public_id, $driver->public_id]; + } + + protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void + { + $this->membershipCalls[] = ['remove-driver', $fleet->public_id, $driver->public_id]; + } + protected function queryFleets(Request $request) { return $this->queryResults ?? [['uuid' => 'fleet-uuid']]; @@ -90,6 +170,14 @@ public function delete() } } +class FleetOpsApiFleetVehicleFake extends Vehicle +{ +} + +class FleetOpsApiFleetDriverFake extends Driver +{ +} + function fleetopsCreateFleetRequest(array $input): CreateFleetRequest { return CreateFleetRequest::create('/api/v1/fleets', 'POST', $input); @@ -100,6 +188,14 @@ function fleetopsUpdateFleetRequest(array $input): UpdateFleetRequest return UpdateFleetRequest::create('/api/v1/fleets/fleet-public', 'PUT', $input); } +function fleetopsFleetFake(array $attributes): FleetOpsApiFleetFake +{ + $fleet = new FleetOpsApiFleetFake(); + $fleet->setRawAttributes($attributes); + + return $fleet; +} + test('api fleet controller creates fleets with service area resolution', function () { session(['company' => 'company-uuid']); @@ -111,28 +207,178 @@ function fleetopsUpdateFleetRequest(array $input): UpdateFleetRequest 'ignored' => 'not copied', ])); + // The pre-expansion contract — name plus service area — still behaves the + // way clients written against it expect. expect($response['resource'])->toBe('fleet') - ->and($controller->serviceAreaLookups)->toBe([ - [ - 'service_areas', - [ - 'public_id' => 'service-area-public', - 'company_uuid' => 'company-uuid', - ], - ], + ->and($controller->relationLookups)->toBe([ + [ServiceArea::class, 'service-area-public'], ]) ->and($controller->createdFleets[0])->toBe([ 'name' => 'Downtown Fleet', + 'service_area_uuid' => 'service-area-public-uuid', 'company_uuid' => 'company-uuid', - 'service_area_uuid' => 'service-area-uuid', ]); }); +test('api fleet controller accepts every safe fleet field and resolves each relationship', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + + $controller->create(fleetopsCreateFleetRequest([ + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'parent_fleet' => 'fleet_parent123', + 'vendor' => 'vendor_123', + 'service_area' => 'service_area_123', + 'zone' => 'zone_123', + 'photo' => 'file_123', + ])); + + expect($controller->createdFleets[0])->toBe([ + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'service_area_uuid' => 'service_area_123-uuid', + 'zone_uuid' => 'zone_123-uuid', + 'vendor_uuid' => 'vendor_123-uuid', + 'parent_fleet_uuid' => 'fleet_parent123-uuid', + 'image_uuid' => 'file_123-uuid', + 'company_uuid' => 'company-uuid', + ])->and($controller->relationLookups)->toBe([ + [ServiceArea::class, 'service_area_123'], + [Zone::class, 'zone_123'], + [Vendor::class, 'vendor_123'], + [Fleet::class, 'fleet_parent123'], + [File::class, 'file_123'], + ]); +}); + +test('api fleet controller input excludes tenancy raw relation and generated columns', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + + $input = $controller->inputForTest(new Request([ + 'name' => 'Carpool', + 'company_uuid' => 'someone-elses-company', + 'uuid' => 'forged', + '_key' => 'forged', + 'public_id' => 'fleet_forged', + 'slug' => 'forged', + 'service_area_uuid' => 'forged', + 'zone_uuid' => 'forged', + 'vendor_uuid' => 'forged', + 'parent_fleet_uuid' => 'forged', + 'image_uuid' => 'forged', + ])); + + expect(array_keys($input))->toBe(['name']); +}); + +test('api fleet controller creates a root fleet when no parent is supplied', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->create(fleetopsCreateFleetRequest(['name' => 'Root Fleet'])); + + expect($controller->createdFleets[0])->not->toHaveKey('parent_fleet_uuid'); +}); + +test('api fleet controller clears the parent fleet when it is sent null', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->fleet = fleetopsFleetFake([ + 'uuid' => 'child-uuid', + 'public_id' => 'fleet_child', + 'parent_fleet_uuid' => 'parent-uuid', + ]); + + $controller->update('fleet_child', fleetopsUpdateFleetRequest(['parent_fleet' => null])); + + expect($controller->fleet->updates[0])->toBe(['parent_fleet_uuid' => null]) + ->and($controller->relationLookups)->toBe([]); +}); + +test('api fleet controller rejects a fleet that names itself as its parent', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet_self-uuid', 'public_id' => 'fleet_self']); + + $response = $controller->update('fleet_self', fleetopsUpdateFleetRequest(['parent_fleet' => 'fleet_self'])); + + expect($response)->toBe([ + 'json' => ['error' => 'A fleet cannot be its own parent fleet.'], + 'status' => 422, + ])->and($controller->fleet->updates)->toBe([]); +}); + +test('api fleet controller rejects a parent that sits below the fleet in the tree', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->fleet = fleetopsFleetFake(['uuid' => 'root-uuid', 'public_id' => 'fleet_root']); + + // grandchild -> child -> root: making the grandchild the root's parent + // would close the loop. + $controller->parentChain = [ + 'fleet_grandchild-uuid' => 'fleet_child-uuid', + 'fleet_child-uuid' => 'root-uuid', + ]; + + $response = $controller->update('fleet_root', fleetopsUpdateFleetRequest([ + 'parent_fleet' => 'fleet_grandchild', + ])); + + expect($response)->toBe([ + 'json' => ['error' => 'A fleet cannot be assigned beneath one of its own subfleets.'], + 'status' => 422, + ])->and($controller->fleet->updates)->toBe([]); +}); + +test('api fleet controller accepts a parent that is not an ancestor of the fleet', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_child']); + + $controller->parentChain = ['fleet_parent-uuid' => 'unrelated-root-uuid']; + + $controller->update('fleet_child', fleetopsUpdateFleetRequest(['parent_fleet' => 'fleet_parent'])); + + expect($controller->fleet->updates[0])->toBe(['parent_fleet_uuid' => 'fleet_parent-uuid']); +}); + +test('api fleet controller rejects relationships that belong to another company', function () { + session(['company' => 'company-uuid']); + + foreach (['parent_fleet', 'vendor', 'zone', 'service_area'] as $relation) { + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->unresolvable = ['other_company_id']; + + $created = $controller->create(fleetopsCreateFleetRequest([ + 'name' => 'Carpool', + $relation => 'other_company_id', + ])); + + // A cross-company identifier is answered exactly as a missing one, so + // the response cannot be used to discover another organization's data. + expect($created)->toBe([ + 'json' => ['error' => 'No ' . str_replace('_', ' ', $relation) . ' resource found for the identifier provided.'], + 'status' => 404, + ]); + } +}); + test('api fleet controller updates queries finds and deletes fleets', function () { session(['company' => 'company-uuid']); - $fleet = new FleetOpsApiFleetFake(); - $fleet->setRawAttributes(['uuid' => 'fleet-uuid', 'name' => 'Old Fleet']); + $fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'name' => 'Old Fleet']); $controller = new FleetOpsApiFleetControllerProbe(); $controller->fleet = $fleet; @@ -149,7 +395,7 @@ function fleetopsUpdateFleetRequest(array $input): UpdateFleetRequest expect($updated)->toBe(['resource' => 'fleet', 'fleet' => $fleet]) ->and($fleet->updates[0])->toBe([ 'name' => 'Updated Fleet', - 'service_area_uuid' => 'service-area-uuid', + 'service_area_uuid' => 'service-area-public-uuid', ]) ->and($query)->toBe([ 'collection' => 'fleet', @@ -174,3 +420,66 @@ function fleetopsUpdateFleetRequest(array $input): UpdateFleetRequest ->and($controller->find('missing-fleet', new Request()))->toBe($expected) ->and($controller->delete('missing-fleet', new Request()))->toBe($expected); }); + +test('api fleet controller answers membership changes with a stable public id shape', function () { + session(['company' => 'company-uuid']); + + $vehicle = new FleetOpsApiFleetVehicleFake(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-uuid', 'public_id' => 'vehicle_123']); + + $driver = new FleetOpsApiFleetDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_123']); + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']); + $controller->vehicle = $vehicle; + $controller->driver = $driver; + + // All four operations answer in the same shape, and never with a uuid. + expect($controller->assignVehicle('fleet_123', 'vehicle_123'))->toBe([ + 'json' => ['fleet' => 'fleet_123', 'vehicle' => 'vehicle_123', 'assigned' => true], + 'status' => 200, + ])->and($controller->removeVehicle('fleet_123', 'vehicle_123'))->toBe([ + 'json' => ['fleet' => 'fleet_123', 'vehicle' => 'vehicle_123', 'assigned' => false], + 'status' => 200, + ])->and($controller->assignDriver('fleet_123', 'driver_123'))->toBe([ + 'json' => ['fleet' => 'fleet_123', 'driver' => 'driver_123', 'assigned' => true], + 'status' => 200, + ])->and($controller->removeDriver('fleet_123', 'driver_123'))->toBe([ + 'json' => ['fleet' => 'fleet_123', 'driver' => 'driver_123', 'assigned' => false], + 'status' => 200, + ])->and($controller->membershipCalls)->toBe([ + ['assign-vehicle', 'fleet_123', 'vehicle_123'], + ['remove-vehicle', 'fleet_123', 'vehicle_123'], + ['assign-driver', 'fleet_123', 'driver_123'], + ['remove-driver', 'fleet_123', 'driver_123'], + ]); +}); + +test('api fleet controller treats an unavailable fleet or resource as not found for membership', function () { + session(['company' => 'company-uuid']); + + $missingFleet = new FleetOpsApiFleetControllerProbe(); + $missingFleet->fleetNotFound = true; + + $missingResource = new FleetOpsApiFleetControllerProbe(); + $missingResource->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']); + $missingResource->resourceNotFound = true; + + // A resource in another company is unavailable, not forbidden — the answer + // is the same one a caller gets for an id that does not exist at all. + expect($missingFleet->assignVehicle('fleet_other', 'vehicle_123'))->toBe([ + 'json' => ['error' => 'Fleet or vehicle resource not found.'], + 'status' => 404, + ])->and($missingFleet->removeDriver('fleet_other', 'driver_123'))->toBe([ + 'json' => ['error' => 'Fleet or driver resource not found.'], + 'status' => 404, + ])->and($missingResource->assignVehicle('fleet_123', 'vehicle_other'))->toBe([ + 'json' => ['error' => 'Fleet or vehicle resource not found.'], + 'status' => 404, + ])->and($missingResource->assignDriver('fleet_123', 'driver_other'))->toBe([ + 'json' => ['error' => 'Fleet or driver resource not found.'], + 'status' => 404, + ])->and($missingFleet->membershipCalls)->toBe([]) + ->and($missingResource->membershipCalls)->toBe([]); +}); diff --git a/server/tests/ApiPartControllerContractsTest.php b/server/tests/ApiPartControllerContractsTest.php index 19cfd663b..4848cdbe0 100644 --- a/server/tests/ApiPartControllerContractsTest.php +++ b/server/tests/ApiPartControllerContractsTest.php @@ -26,7 +26,7 @@ protected function createPart(array $input): Part return $part; } - protected function resolveModel(string $modelClass, string $id): Illuminate\Database\Eloquent\Model + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Illuminate\Database\Eloquent\Model { if ($this->partNotFound) { throw new ModelNotFoundException(); @@ -37,7 +37,7 @@ protected function resolveModel(string $modelClass, string $id): Illuminate\Data return $this->part; } - protected function resolveUuid(string $modelClass, ?string $id): ?string + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { $this->resolvedUuids[] = [$modelClass, $id]; diff --git a/server/tests/ApiVehicleControllerContractsTest.php b/server/tests/ApiVehicleControllerContractsTest.php index fed856dce..88c77f948 100644 --- a/server/tests/ApiVehicleControllerContractsTest.php +++ b/server/tests/ApiVehicleControllerContractsTest.php @@ -5,6 +5,9 @@ use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Warranty; +use Fleetbase\Models\Category; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -13,7 +16,8 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController public ?FleetOpsApiVehicleCrudFake $vehicle = null; public ?FleetOpsApiDriverCrudFake $driver = null; public array $createdVehicles = []; - public array $vendorLookups = []; + public array $relationLookups = []; + public array $unresolvable = []; public mixed $queryResults = null; public bool $vehicleNotFound = false; public bool $driverNotFound = false; @@ -23,11 +27,26 @@ public function inputForTest(Request $request): array return $this->vehicleInputFromRequest($request); } - protected function getVendorUuid(string $table, array $where): ?string + /** + * Stand in for the company-scoped public-id lookup. + * + * Anything listed in `$unresolvable` behaves as a cross-company or missing + * identifier does in production: the lookup finds nothing inside the + * caller's company and raises. + */ + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { - $this->vendorLookups[] = [$table, $where]; + if (empty($id)) { + return null; + } + + $this->relationLookups[] = [$modelClass, $id]; + + if (in_array($id, $this->unresolvable, true)) { + throw (new ModelNotFoundException())->setModel($modelClass, $id); + } - return 'vendor-uuid'; + return strtolower(class_basename($modelClass)) . '-uuid'; } protected function createVehicle(array $input): Vehicle @@ -240,8 +259,8 @@ function fleetopsUpdateVehicleRequest(array $input): UpdateVehicleRequest $vehicle = $response['vehicle']; expect($response['resource'])->toBe('vehicle') - ->and($controller->vendorLookups)->toBe([ - ['vendors', ['public_id' => 'vendor-public', 'company_uuid' => 'company-uuid']], + ->and($controller->relationLookups)->toBe([ + [Vendor::class, 'vendor-public'], ]) ->and($controller->createdVehicles[0])->toMatchArray([ 'status' => 'active', @@ -292,9 +311,11 @@ function fleetopsUpdateVehicleRequest(array $input): UpdateVehicleRequest 'make' => 'Nissan', 'model' => 'NV350', 'vin' => 'NEWVIN', - 'online' => 0, 'vendor_uuid' => 'vendor-uuid', ]) + // A partial update must not carry an unrequested `online` default: the + // absent key means "leave it alone", not "take the vehicle offline". + ->and($filledInput)->not->toHaveKey('online') ->and($filledInput)->toHaveKey('location') ->and($vehicle->unassignedDriver)->toBeTrue() ->and($vehicle->savedForTest)->toBeTrue() @@ -401,3 +422,149 @@ function fleetopsUpdateVehicleRequest(array $input): UpdateVehicleRequest ->and($input)->not->toHaveKey('company_uuid') ->and($input)->not->toHaveKey('uuid'); }); + +test('api vehicle controller accepts every safe vehicle field the model exposes', function () { + // Data-driven rather than one test per column: the point is parity between + // what the model can store and what the public contract will accept, so the + // assertion is over the whole set. + session(['company' => 'company-uuid']); + + $payload = [ + // Identity and description + 'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van', + 'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024, + 'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1', + 'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001', + 'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001', + // Measurement and operation + 'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12, + 'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l', + 'online' => true, 'status' => 'available', + // Body, capacity and dimensions + 'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb', + 'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5, + 'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06, + 'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400, + 'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70, + // Lifecycle and financing + 'financing_status' => 'financed', 'loan_number_of_payments' => 48, + 'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD', + 'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000, + 'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5, + 'current_value' => 38000, 'acquisition_cost' => 52000, + 'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02', + // Regulatory and engine specifications + 'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false, + 'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue', + 'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline', + 'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168, + 'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750, + 'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4', + // Structured and descriptive + 'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool', + 'meta' => ['depot' => 'north'], + // Orchestrator + 'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25, + 'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40, + 'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true, + ]; + + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + $input = $controller->inputForTest(new Request($payload)); + + $missing = array_values(array_diff(array_keys($payload), array_keys($input))); + + expect($missing)->toBe([]) + ->and($input)->toMatchArray($payload); +}); + +test('api vehicle controller resolves every public relationship input to a scoped uuid', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + $input = $controller->inputForTest(new Request([ + 'vendor' => 'vendor_abc', + 'category' => 'category_abc', + 'warranty' => 'warranty_abc', + 'photo' => 'file_abc', + ])); + + expect($input)->toMatchArray([ + 'vendor_uuid' => 'vendor-uuid', + 'category_uuid' => 'category-uuid', + 'warranty_uuid' => 'warranty-uuid', + 'photo_uuid' => 'file-uuid', + ]) + ->and($controller->relationLookups)->toBe([ + [Vendor::class, 'vendor_abc'], + [Category::class, 'category_abc'], + [Warranty::class, 'warranty_abc'], + [Fleetbase\Models\File::class, 'file_abc'], + ]); +}); + +test('api vehicle controller clears a relationship when the input is sent empty', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + $input = $controller->inputForTest(fleetopsUpdateVehicleRequest(['vendor' => null])); + + expect($input)->toHaveKey('vendor_uuid') + ->and($input['vendor_uuid'])->toBeNull() + ->and($controller->relationLookups)->toBe([]); +}); + +test('api vehicle controller rejects a relationship that belongs to another company', function () { + session(['company' => 'company-uuid']); + + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + $controller->unresolvable = ['vendor_other_company']; + + $created = $controller->create(fleetopsCreateVehicleRequest([ + 'make' => 'Ford', + 'vendor' => 'vendor_other_company', + ])); + + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + $controller->vehicle = new FleetOpsApiVehicleCrudFake(); + $controller->unresolvable = ['vendor_other_company']; + + $updated = $controller->update('vehicle-public', fleetopsUpdateVehicleRequest([ + 'vendor' => 'vendor_other_company', + ])); + + // A cross-company identifier is answered exactly as a missing one, so the + // response cannot be used to discover what another organization holds. + expect($created)->toBe([ + 'json' => ['error' => 'No vendor resource found for the identifier provided.'], + 'status' => 404, + ])->and($updated)->toBe([ + 'json' => ['error' => 'No vendor resource found for the identifier provided.'], + 'status' => 404, + ]); +}); + +test('api vehicle controller input still excludes server managed and internal columns', function () { + $controller = new FleetOpsApiVehicleCrudControllerProbe(); + + $input = $controller->inputForTest(new Request([ + 'make' => 'Ford', + 'company_uuid' => 'someone-elses-company', + 'uuid' => 'forged', + '_key' => 'forged', + 'public_id' => 'vehicle_forged', + 'slug' => 'forged', + 'vendor_uuid' => 'forged', + 'category_uuid' => 'forged', + 'warranty_uuid' => 'forged', + 'photo_uuid' => 'forged', + 'telematic_uuid' => 'forged', + // Written by the VIN decoder and by telematics ingestion respectively; + // a caller must not be able to overwrite either. + 'vin_data' => ['manufacturer' => 'forged'], + 'telematics' => ['speed' => 999], + 'avatar_url' => 'forged', + ])); + + expect(array_keys($input))->toBe(['make']); +}); diff --git a/server/tests/ControllerFilterContractsTest.php b/server/tests/ControllerFilterContractsTest.php index d9f68382d..a4907cebd 100644 --- a/server/tests/ControllerFilterContractsTest.php +++ b/server/tests/ControllerFilterContractsTest.php @@ -272,6 +272,91 @@ function fleetopsProtectedMethod(string $class, string $method): ReflectionMetho return $reflection; } +/** + * Boot an in-memory database holding one row per relationship a public filter + * can be asked about. + * + * Relationship filters no longer compare a caller-supplied value against a uuid + * column: the public API deals in public ids, so the filter resolves the id to + * the uuids it stands for first. That resolution is a real query, so these + * assertions need a real connection. + * + * @return array uuids keyed by table + */ +function fleetopsFilterRelationDatabase(): array +{ + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public Illuminate\Database\SQLiteConnection $c) + { + } + + public function connection($name = null): Illuminate\Database\SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + foreach (['fleets', 'vendors', 'zones', 'service_areas', 'drivers', 'vehicles', 'users'] as $table) { + if ($schema->hasTable($table)) { + $schema->drop($table); + } + + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $uuids = [ + 'fleets' => '99999999-9999-4999-8999-999999999001', + 'vendors' => '99999999-9999-4999-8999-999999999002', + 'zones' => '99999999-9999-4999-8999-999999999003', + 'service_areas' => '99999999-9999-4999-8999-999999999004', + 'drivers' => '99999999-9999-4999-8999-999999999005', + 'vehicles' => '99999999-9999-4999-8999-999999999006', + 'users' => '99999999-9999-4999-8999-999999999007', + ]; + + $publicIds = [ + 'fleets' => 'fleet_filterone12', + 'vendors' => 'vendor_filterone1', + 'zones' => 'zone_filterone123', + 'service_areas' => 'service_area_fone', + 'drivers' => 'driver_filterone1', + 'vehicles' => 'vehicle_filterone', + 'users' => 'user_filterone123', + ]; + + foreach ($uuids as $table => $uuid) { + $connection->table($table)->insert([ + 'uuid' => $uuid, + 'public_id' => $publicIds[$table], + 'company_uuid' => 'company-uuid', + ]); + } + + // The driver scope requires a linked user row to exist. + $connection->table('drivers')->where('uuid', $uuids['drivers'])->update(['user_uuid' => $uuids['users']]); + + return ['uuids' => $uuids, 'publicIds' => $publicIds]; +} + function fleetopsFilterWithBuilder(string $class, FleetOpsControllerFilterQuery $builder): object { $filter = (new ReflectionClass($class))->newInstanceWithoutConstructor(); @@ -456,6 +541,8 @@ public function get(string $key): ?string }); test('vehicle filter records identity relationship fleet and telematic filters', function () { + ['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase(); + $query = new FleetOpsControllerFilterQuery(); $filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query); @@ -465,14 +552,17 @@ public function get(string $key): ?string $filter->display_name('sprinter'); $filter->vin('vin-123'); $filter->publicId('vehicle-public'); + $filter->internalId('VEH-42'); $filter->plateNumber('ABC-123'); $filter->vehicleMake('Mercedes'); $filter->vehicleModel('Sprinter'); $filter->vehicleYear('2026'); $filter->driver('unassigned'); + $filter->driver($publicIds['drivers']); $filter->vendor(null); + $filter->vendor($publicIds['vendors']); $filter->driverUuid('driver-uuid'); - $filter->fleet('fleet-uuid'); + $filter->fleet($publicIds['fleets']); $filter->assignedFleet('false'); $filter->telematicUuid('telematic-uuid'); $filter->createdAt(['2026-01-01', '2026-01-31']); @@ -483,19 +573,28 @@ public function get(string $key): ?string ->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter']) ->and($query->calls)->toContain(['searchWhere', 'vin', 'vin-123']) ->and($query->calls)->toContain(['searchWhere', 'public_id', 'vehicle-public']) + ->and($query->calls)->toContain(['searchWhere', 'internal_id', 'VEH-42']) ->and($query->calls)->toContain(['searchWhere', 'plate_number', 'ABC-123']) ->and($query->calls)->toContain(['searchWhere', 'make', 'Mercedes']) ->and($query->calls)->toContain(['searchWhere', 'model', 'Sprinter']) ->and($query->calls)->toContain(['searchWhere', 'year', '2026']) ->and($query->calls)->toContain(['whereDoesntHave', 'driver']) - ->and($query->calls)->toContain(['whereDoesntHave', 'fleets']); + ->and($query->calls)->toContain(['whereDoesntHave', 'fleets']) + // Public ids are resolved to the uuids the columns actually hold. + ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]]); + + $nested = collect($query->calls)->where(0, 'whereHas')->flatMap(fn ($call) => $call[2])->values()->all(); expect(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('driver', 'fleets', 'devices') + ->and($nested)->toContain(['whereIn', 'uuid', [$uuids['drivers']]]) + ->and($nested)->toContain(['whereIn', 'fleet_uuid', [$uuids['fleets']]]) ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1) ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1); }); test('fleet filter records hierarchy relationship scalar status and date filters', function () { + ['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase(); + $query = new FleetOpsControllerFilterQuery(); $filter = fleetopsFilterWithBuilder(FleetFilter::class, $query); @@ -504,10 +603,10 @@ public function get(string $key): ?string $filter->query('dispatch'); $filter->parentsOnly(true); $filter->parentsOnly(false); - $filter->serviceArea('service-area-uuid'); - $filter->zone('zone-uuid'); - $filter->parentFleet('parent-fleet-uuid'); - $filter->vendor('vendor-uuid'); + $filter->serviceArea($publicIds['service_areas']); + $filter->zone($publicIds['zones']); + $filter->parentFleet($publicIds['fleets']); + $filter->vendor($publicIds['vendors']); $filter->publicId('fleet-public'); $filter->task('delivery'); $filter->name('North Fleet'); @@ -515,16 +614,26 @@ public function get(string $key): ?string $filter->createdAt('2026-01-01'); $filter->updatedAt(['2026-02-01', '2026-02-28']); + $nested = collect($query->calls)->where(0, 'where')->pluck(1)->all(); + expect($query->calls)->toContain(['where', ['company_uuid', 'company-uuid']]) ->and($query->calls)->toContain(['with', ['serviceArea', 'zone']]) ->and($query->calls)->toContain(['whereNull', 'parent_fleet_uuid']) - ->and($query->calls)->toContain(['searchWhere', 'parent_fleet_uuid', 'parent-fleet-uuid']) ->and($query->calls)->toContain(['searchWhere', 'public_id', 'fleet-public']) ->and($query->calls)->toContain(['searchWhere', 'task', 'delivery']) ->and($query->calls)->toContain(['searchWhere', 'name', 'North Fleet']) - ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']]); - - expect(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('serviceArea', 'zone', 'parent_fleet', 'vendor') + ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']]) + // Every hierarchy and relationship filter now resolves a public id to + // the uuid column it stands for, rather than comparing the public id + // against a uuid — which never matched — or against a `zone_uuid` + // column that does not exist on `zones`. + ->and($query->calls)->toContain(['whereIn', 'service_area_uuid', [$uuids['service_areas']]]) + ->and($query->calls)->toContain(['whereIn', 'zone_uuid', [$uuids['zones']]]) + ->and($query->calls)->toContain(['whereIn', 'parent_fleet_uuid', [$uuids['fleets']]]) + ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]]) + // `?query=` searches the fleet's own columns; it used to reach for a + // `user` relation Fleet does not have. + ->and(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->not->toContain('user') ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1) ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1); }); @@ -1224,9 +1333,13 @@ public function get(string $key): ?string test('vehicle and fuel report filters cover alternate identity and date branches', function () { // Vehicle filter: unassigned drivers, blank vendor early return, inverse date // branches, fleet unassignment and blank telematic early return + fleetopsFilterRelationDatabase(); + $query = new FleetOpsControllerFilterQuery(); $filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query); $filter->driver('unassigned'); + // A public request may not pass a uuid, so nothing resolves and the filter + // matches no vehicle rather than silently matching all of them. $filter->driver('c4c4c4c4-4444-4444-8444-444444444444'); $filter->vendor(null); $filter->createdAt('2026-02-01'); @@ -1240,7 +1353,7 @@ public function get(string $key): ?string ->and($query->calls)->toContain(['whereDoesntHave', 'fleets']) ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1) ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1) - ->and($nestedVehicle)->toContain(['where', ['uuid', 'c4c4c4c4-4444-4444-8444-444444444444']]); + ->and($nestedVehicle)->toContain(['whereIn', 'uuid', []]); // Fuel report filter: uuid, public-id and free-text identity branches // plus the inverse date branches diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php index f33d32126..aefd4ed75 100644 --- a/server/tests/ControllerHelperContractsTest.php +++ b/server/tests/ControllerHelperContractsTest.php @@ -1093,7 +1093,9 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti 'time_window_start' => '08:00', 'time_window_end' => '17:00', 'return_to_depot' => true, - 'vendor' => 'vendor-public', + // `vendor` is deliberately absent: it is no longer a scalar in this + // projection but a public id resolved against the caller's company, + // which is asserted in ApiVehicleControllerContractsTest. 'driver' => 'driver-public', ]); $locationInput = $controller->callHelper('withCoordinateLocation', [], new Request([ @@ -1102,8 +1104,8 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti ])); $tracking = $controller->callHelper('positionDataFromTrackingInput', 1.2816, 103.851, 12, 180, 55); + // Key order follows the allowlist, not the request body. expect($controller->callHelper('vehicleInputFromRequest', $request))->toBe([ - 'status' => 'active', 'make' => 'Toyota', 'model' => 'HiAce', 'year' => 2025, @@ -1111,13 +1113,14 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti 'type' => 'van', 'plate_number' => 'SG-1234', 'vin' => 'VIN123', - 'meta' => ['temperature' => 'ambient'], 'online' => false, + 'status' => 'active', 'location' => ['latitude' => 1.30, 'longitude' => 103.80], 'altitude' => 10, 'heading' => 90, 'speed' => 45, 'payload_capacity' => 1200, + 'meta' => ['temperature' => 'ambient'], 'payload_capacity_volume' => 8, 'payload_capacity_pallets' => 2, 'payload_capacity_parcels' => 80, diff --git a/server/tests/DriverFilterExecutionTest.php b/server/tests/DriverFilterExecutionTest.php index 47ebfebf5..d8f58cc78 100644 --- a/server/tests/DriverFilterExecutionTest.php +++ b/server/tests/DriverFilterExecutionTest.php @@ -110,6 +110,63 @@ function fleetopsDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder, arr return $filter; } +/** + * Relationship filters resolve public ids to uuids, which is a real query. + * + * @return array> + */ +function fleetopsDriverFilterRelationDatabase(): array +{ + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + app()->instance('db', new class($connection) { + public function __construct(public Illuminate\Database\SQLiteConnection $c) + { + } + + public function connection($name = null) + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + foreach (['vendors', 'vehicles', 'fleets'] as $table) { + if ($schema->hasTable($table)) { + $schema->drop($table); + } + + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $rows = [ + 'vendors' => ['uuid' => '88888888-8888-4888-8888-888888888801', 'public_id' => 'vendor_dfilterone1'], + 'vehicles' => ['uuid' => '88888888-8888-4888-8888-888888888802', 'public_id' => 'vehicle_dfilteron'], + 'fleets' => ['uuid' => '88888888-8888-4888-8888-888888888803', 'public_id' => 'fleet_dfilterone1'], + ]; + + foreach ($rows as $table => $row) { + $connection->table($table)->insert($row + ['company_uuid' => 'company_test']); + } + + return $rows; +} + test('driver filter applies internal public and text search scopes', function () { $builder = new FleetOpsRecordingDriverFilterBuilder(); $filter = fleetopsDriverFilter($builder); @@ -128,27 +185,46 @@ function fleetopsDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder, arr }); test('driver filter assignment and identity filters execute uuid and public branches', function () { + $rows = fleetopsDriverFilterRelationDatabase(); $builder = new FleetOpsRecordingDriverFilterBuilder(); $filter = fleetopsDriverFilter($builder); $uuid = (string) Str::uuid(); - $filter->facilitator('vendor_uuid'); - $filter->vendor('vendor_uuid'); + $filter->facilitator($rows['vendors']['public_id']); + $filter->vendor($rows['vendors']['public_id']); $filter->vehicle('unassigned'); + // The console's uuid branch — this filter is constructed on an /int route. $filter->vehicle($uuid); + $filter->vehicle($rows['vehicles']['public_id']); + // An identifier that resolves to nothing still narrows by search rather + // than raising. $filter->vehicle('vehicle_public'); $filter->driversLicenseNumber('DL-123'); $filter->phone('+15551112222'); $filter->country('SG,MY'); $filter->country('MN'); $filter->status('available,offline'); - $filter->fleet('fleet_uuid'); + $filter->fleet($rows['fleets']['public_id']); + + $whereIns = collect($builder->methodCalls('whereIn'))->map(fn ($call) => [$call[1], $call[2]])->all(); + $nested = collect($builder->methodCalls('whereHas')) + ->flatMap(fn ($call) => $call[2]->calls) + ->filter(fn ($call) => $call[0] === 'whereIn') + ->map(fn ($call) => [$call[1], $call[2]]) + ->all(); expect($builder->called('whereNull'))->toBeTrue() - ->and($builder->called('where'))->toBeTrue() ->and($builder->called('whereHas'))->toBeTrue() - ->and($builder->called('whereIn'))->toBeTrue() - ->and($builder->called('searchWhere'))->toBeTrue(); + ->and($builder->called('searchWhere'))->toBeTrue() + // The vendor filter used to compare a public id against `vendor_uuid` + // directly, which could never match. + ->and($whereIns)->toContain(['vendor_uuid', [$rows['vendors']['uuid']]]) + ->and($whereIns)->toContain(['vehicle_uuid', [$rows['vehicles']['uuid']]]) + ->and($nested)->toContain(['fleet_uuid', [$rows['fleets']['uuid']]]) + // `?phone=` reached for a relation that does not exist on Driver; it now + // searches the linked user. + ->and(collect($builder->methodCalls('whereHas'))->pluck(1)->all())->toContain('user') + ->and(collect($builder->methodCalls('whereHas'))->pluck(1)->all())->not->toContain('phone'); }); test('driver filter date and nearby coordinate filters execute query operations', function () { diff --git a/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php b/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php index 3c764a2f1..03a907551 100644 --- a/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php @@ -41,7 +41,7 @@ protected function queryDevicesWithRequest(Request $request, callable $callback) return [['uuid' => 'device-a'], ['uuid' => 'device-b']]; } - protected function resolveModel(string $modelClass, string $id): EloquentModel + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): EloquentModel { $key = $modelClass . ':' . $id; diff --git a/server/tests/Feature/Http/Api/FleetMembershipTest.php b/server/tests/Feature/Http/Api/FleetMembershipTest.php new file mode 100644 index 000000000..50bc5b213 --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetMembershipTest.php @@ -0,0 +1,210 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + // A model dispatcher brings the lifecycle observers with it — the HTTP cache + // observer calls the `responsecache` binding on every create and delete — + // so satisfy it rather than letting an unrelated facade fail the assertion. + if (EloquentModel::getEventDispatcher() && !app()->bound('responsecache')) { + app()->instance('responsecache', new class { + public function clear(): void + { + } + + public function forget($uris): void + { + } + }); + } + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + + foreach (['fleet_vehicles' => 'vehicle_uuid', 'fleet_drivers' => 'driver_uuid'] as $table => $subjectColumn) { + $schema->create($table, function ($blueprint) use ($subjectColumn) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('_key')->nullable(); + $blueprint->string('fleet_uuid')->nullable(); + $blueprint->string($subjectColumn)->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + return $connection; +} + +function fleetopsMembershipController(): FleetController +{ + return new FleetController(); +} + +function fleetopsMembershipInvoke(string $method, ...$arguments) +{ + $controller = fleetopsMembershipController(); + $reflection = new ReflectionMethod(FleetController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($controller, ...$arguments); +} + +function fleetopsMembershipFleet(string $uuid, string $publicId): Fleet +{ + $fleet = new Fleet(); + $fleet->setRawAttributes(['uuid' => $uuid, 'public_id' => $publicId], true); + + return $fleet; +} + +function fleetopsMembershipVehicle(string $uuid, string $publicId): Vehicle +{ + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => $uuid, 'public_id' => $publicId], true); + + return $vehicle; +} + +function fleetopsMembershipDriver(string $uuid, string $publicId, ?string $vehicleUuid = null): Driver +{ + $driver = new Driver(); + $driver->setRawAttributes([ + 'uuid' => $uuid, + 'public_id' => $publicId, + 'vehicle_uuid' => $vehicleUuid, + ], true); + + return $driver; +} + +test('assigning a vehicle to a fleet is idempotent and creates no duplicate membership', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + + $rows = $connection->table('fleet_vehicles')->get(); + + expect($rows)->toHaveCount(1) + ->and($rows[0]->fleet_uuid)->toBe('fleet-uuid-1') + ->and($rows[0]->vehicle_uuid)->toBe('vehicle-uuid-1') + ->and($rows[0]->deleted_at)->toBeNull(); +}); + +test('removing a vehicle from a fleet is a safe no-op when repeated', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + // Removing before anything was ever assigned must not raise. + fleetopsMembershipInvoke('removeVehicleFromFleet', $fleet, $vehicle); + + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('removeVehicleFromFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('removeVehicleFromFleet', $fleet, $vehicle); + + expect(FleetVehicle::where(['fleet_uuid' => 'fleet-uuid-1', 'vehicle_uuid' => 'vehicle-uuid-1'])->count())->toBe(0) + ->and($connection->table('fleet_vehicles')->whereNotNull('deleted_at')->count())->toBe(1); +}); + +test('a soft deleted vehicle membership is restored rather than duplicated', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('removeVehicleFromFleet', $fleet, $vehicle); + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + + // One row, live again — not a second row shadowing a tombstone. + expect($connection->table('fleet_vehicles')->count())->toBe(1) + ->and(FleetVehicle::where(['fleet_uuid' => 'fleet-uuid-1', 'vehicle_uuid' => 'vehicle-uuid-1'])->count())->toBe(1); +}); + +test('assigning a driver to a fleet is idempotent and restores a removed membership', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $driver = fleetopsMembershipDriver('driver-uuid-1', 'driver_123'); + + fleetopsMembershipInvoke('assignDriverToFleet', $fleet, $driver); + fleetopsMembershipInvoke('assignDriverToFleet', $fleet, $driver); + fleetopsMembershipInvoke('removeDriverFromFleet', $fleet, $driver); + fleetopsMembershipInvoke('removeDriverFromFleet', $fleet, $driver); + fleetopsMembershipInvoke('assignDriverToFleet', $fleet, $driver); + + expect($connection->table('fleet_drivers')->count())->toBe(1) + ->and(FleetDriver::where(['fleet_uuid' => 'fleet-uuid-1', 'driver_uuid' => 'driver-uuid-1'])->count())->toBe(1); +}); + +test('a fleet membership change leaves the driver its vehicle and its other fleets', function () { + $connection = fleetopsMembershipDatabase(); + $driver = fleetopsMembershipDriver('driver-uuid-1', 'driver_123', 'vehicle-uuid-9'); + $fleetOne = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_one'); + $fleetTwo = fleetopsMembershipFleet('fleet-uuid-2', 'fleet_two'); + + fleetopsMembershipInvoke('assignDriverToFleet', $fleetOne, $driver); + fleetopsMembershipInvoke('assignDriverToFleet', $fleetTwo, $driver); + fleetopsMembershipInvoke('removeDriverFromFleet', $fleetOne, $driver); + + $live = FleetDriver::where('driver_uuid', 'driver-uuid-1')->pluck('fleet_uuid')->all(); + + expect($live)->toBe(['fleet-uuid-2']) + // The pivot is the only thing touched: the driver's current vehicle is + // untouched and the driver itself is still there. + ->and($driver->vehicle_uuid)->toBe('vehicle-uuid-9') + ->and($connection->table('fleet_drivers')->count())->toBe(2); +}); + +test('a vehicle stays in its other fleets when removed from one', function () { + fleetopsMembershipDatabase(); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + $fleetOne = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_one'); + $fleetTwo = fleetopsMembershipFleet('fleet-uuid-2', 'fleet_two'); + + fleetopsMembershipInvoke('assignVehicleToFleet', $fleetOne, $vehicle); + fleetopsMembershipInvoke('assignVehicleToFleet', $fleetTwo, $vehicle); + fleetopsMembershipInvoke('removeVehicleFromFleet', $fleetTwo, $vehicle); + + expect(FleetVehicle::where('vehicle_uuid', 'vehicle-uuid-1')->pluck('fleet_uuid')->all())->toBe(['fleet-uuid-1']); +}); diff --git a/server/tests/Feature/Http/Api/FleetPublicContractTest.php b/server/tests/Feature/Http/Api/FleetPublicContractTest.php new file mode 100644 index 000000000..5d9292f8b --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetPublicContractTest.php @@ -0,0 +1,232 @@ +uri; + } +} + +function fleetopsFleetResourceRequest(bool $internal, array $query = []): Request +{ + $uri = $internal ? 'api/int/v1/fleet-ops/fleets/fleet_123' : 'api/v1/fleets/fleet_123'; + $request = Request::create('/' . $uri, 'GET', $query); + $request->setRouteResolver(fn () => new FleetOpsFleetResourceRouteFixture($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * Boot an in-memory database holding one fleet with every relationship set. + * + * The resource resolves each assignment to a public id, which is a real read, + * and the custom-field merge and photo accessor both touch the database as + * well — so the shape of the public payload can only be asserted against a + * real connection. + */ +function fleetopsFleetResourceDatabase(): Illuminate\Database\SQLiteConnection +{ + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public Illuminate\Database\SQLiteConnection $c) + { + } + + public function connection($name = null): Illuminate\Database\SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + + $schema->create('fleets', function ($blueprint) { + $blueprint->increments('id'); + foreach ([ + 'uuid', '_key', 'public_id', 'company_uuid', 'service_area_uuid', 'zone_uuid', + 'vendor_uuid', 'parent_fleet_uuid', 'image_uuid', 'name', 'color', 'task', 'status', 'slug', + ] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + foreach (['service_areas', 'zones', 'vendors'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', '_key', 'public_id', 'company_uuid', 'service_area_uuid', 'name', 'type', 'border', 'status'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + foreach (['fleet_drivers' => 'driver_uuid', 'fleet_vehicles' => 'vehicle_uuid'] as $table => $subject) { + $schema->create($table, function ($blueprint) use ($subject) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('fleet_uuid')->nullable(); + $blueprint->string($subject)->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + foreach (['drivers', 'vehicles', 'users'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->boolean('online')->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $schema->create('custom_field_values', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + $connection->table('service_areas')->insert(['uuid' => 'service-area-uuid', 'public_id' => 'service_area_123', 'company_uuid' => 'company-uuid', 'name' => 'Central']); + $connection->table('zones')->insert(['uuid' => 'zone-uuid', 'public_id' => 'zone_123', 'company_uuid' => 'company-uuid', 'name' => 'Downtown']); + $connection->table('vendors')->insert(['uuid' => 'vendor-uuid', 'public_id' => 'vendor_123', 'company_uuid' => 'company-uuid', 'name' => 'Acme']); + $connection->table('fleets')->insert(['uuid' => 'parent-uuid', 'public_id' => 'fleet_parent123', 'company_uuid' => 'company-uuid', 'name' => 'Parent']); + + return $connection; +} + +function fleetopsFleetResourceFixture(array $overrides = []): Fleet +{ + $connection = fleetopsFleetResourceDatabase(); + + $connection->table('fleets')->insert(array_merge([ + 'uuid' => 'fleet-uuid', + 'public_id' => 'fleet_123', + 'company_uuid' => 'company-uuid', + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'service_area_uuid' => 'service-area-uuid', + 'zone_uuid' => 'zone-uuid', + 'vendor_uuid' => 'vendor-uuid', + 'parent_fleet_uuid' => 'parent-uuid', + ], $overrides)); + + return Fleet::where('uuid', 'fleet-uuid')->firstOrFail(); +} + +test('the public fleet resource reports every writable field and each relationship as a public id', function () { + $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve(fleetopsFleetResourceRequest(false)); + + // A caller that just wrote `parent_fleet: "fleet_parent123"` has to be able + // to read the assignment back, and the public contract never exposes the + // uuid the column actually holds. + expect($payload)->toMatchArray([ + 'id' => 'fleet_123', + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'service_area' => 'service_area_123', + 'zone' => 'zone_123', + 'vendor' => 'vendor_123', + 'parent_fleet' => 'fleet_parent123', + ])->and($payload)->not->toHaveKeys([ + 'uuid', + 'public_id', + 'company_uuid', + 'service_area_uuid', + 'zone_uuid', + 'vendor_uuid', + 'parent_fleet_uuid', + 'image_uuid', + ]); +}); + +test('the public fleet resource reports a root fleet with a null parent', function () { + $fleet = fleetopsFleetResourceFixture(['parent_fleet_uuid' => null]); + + $payload = (new FleetResource($fleet))->resolve(fleetopsFleetResourceRequest(false)); + + expect($payload)->toHaveKey('parent_fleet') + ->and($payload['parent_fleet'])->toBeNull(); +}); + +test('the internal fleet resource keeps its nested relationship shape', function () { + $fleet = fleetopsFleetResourceFixture()->load(['serviceArea', 'zone', 'vendor', 'parentFleet']); + + $payload = (new FleetResource($fleet))->resolve(fleetopsFleetResourceRequest(true)); + + // The console reads nested objects off these keys, and reads the counters; + // expanding the public contract must not reshape either. + expect($payload)->toHaveKeys(['uuid', 'public_id', 'drivers_count', 'vehicles_count']) + ->and($payload['uuid'])->toBe('fleet-uuid') + ->and($payload['service_area'])->toBeObject() + ->and($payload['zone'])->toBeObject() + ->and($payload['vendor'])->toBeObject() + ->and($payload['parent_fleet'])->toBeObject(); +}); + +test('the public fleet resource still nests a relationship the caller asked for', function () { + $request = fleetopsFleetResourceRequest(false, ['with' => ['service_area']]); + $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve($request); + + expect($payload['service_area'])->toBeObject() + // Relations that were not requested stay as public ids. + ->and($payload['vendor'])->toBe('vendor_123'); +}); + +test('public fleet membership routes are registered with public id parameters', function () { + $routes = file_get_contents(dirname(__DIR__, 4) . '/src/routes.php'); + + expect($routes) + ->toContain("\$router->post('{id}/vehicles/{vehicle}', 'FleetController@assignVehicle');") + ->toContain("\$router->delete('{id}/vehicles/{vehicle}', 'FleetController@removeVehicle');") + ->toContain("\$router->post('{id}/drivers/{driver}', 'FleetController@assignDriver');") + ->toContain("\$router->delete('{id}/drivers/{driver}', 'FleetController@removeDriver');"); + + $fleetGroup = substr($routes, strpos($routes, '// fleets routes')); + $fleetGroup = substr($fleetGroup, 0, strpos($fleetGroup, '// labels routes')); + + // The literal segments have to be declared before the `{id}` patterns, or + // `vehicles` is swallowed as a fleet id. + expect(strpos($fleetGroup, '{id}/vehicles/{vehicle}'))->toBeLessThan(strpos($fleetGroup, "\$router->get('{id}'")); +}); + +test('the public fleet controller exposes the membership actions', function () { + foreach (['create', 'query', 'find', 'update', 'delete', 'assignVehicle', 'removeVehicle', 'assignDriver', 'removeDriver'] as $method) { + expect(method_exists(FleetController::class, $method))->toBeTrue(); + } +}); diff --git a/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php b/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php index 4d52d9190..46ecba1c2 100644 --- a/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php @@ -44,14 +44,14 @@ protected function queryTransactionsWithRequest(Request $request, callable $call ]; } - protected function resolveUuid(string $modelClass, ?string $id): ?string + protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string { $this->resolvedUuids[] = [$modelClass, $id]; return filled($id) ? $id . '-uuid' : null; } - protected function resolveModel(string $modelClass, string $id): EloquentModel + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): EloquentModel { $key = $modelClass . ':' . $id; diff --git a/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php b/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php index 7d8a2685e..06d076f8c 100644 --- a/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +++ b/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php @@ -78,6 +78,9 @@ public function __call($method, $arguments) 'users' => ['uuid', 'public_id', 'company_uuid', 'name', '_key'], 'places' => ['uuid', 'public_id', 'company_uuid', 'name', 'location', '_key'], 'service_areas' => ['uuid', 'public_id', 'company_uuid', 'name', 'border', '_key'], + // ServiceArea eager loads its zones, so resolving one by public id + // reads this table too. + 'zones' => ['uuid', 'public_id', 'company_uuid', 'service_area_uuid', 'name', 'border', '_key'], 'companies' => ['uuid', 'public_id', 'name', 'country', 'options'], 'service_rates' => ['uuid', 'public_id', 'company_uuid', 'service_area_uuid', 'zone_uuid', 'service_name', 'service_type', 'base_fee', 'per_km_flat_rate_fee', 'rate_calculation_method', 'currency', 'estimated_days', 'duration_terms', 'meta', 'slug', 'internal_id', '_key'], 'service_rate_fees' => ['uuid', 'public_id', 'service_rate_uuid', 'min', 'max', 'fee', 'distance_unit', '_key'], @@ -146,10 +149,13 @@ function fleetopsSmallApiHelper(object $controller): Closure // Fleet battery $fleetHelper = fleetopsSmallApiHelper(new FleetController()); - expect($fleetHelper('getServiceAreaUuid', 'service_areas', ['public_id' => 'sa_smallone1']))->toBe('sa-sm-1'); + // Relationship inputs resolve from a public id, scoped to the caller's company. + expect($fleetHelper('resolveUuid', Fleetbase\FleetOps\Models\ServiceArea::class, 'sa_smallone1'))->toBe('sa-sm-1'); $fleet = $fleetHelper('createFleet', ['company_uuid' => 'company-1', 'name' => 'Battery Fleet']); expect($connection->table('fleets')->count())->toBe(1); $foundFleet = $fleetHelper('findFleet', (string) $connection->table('fleets')->value('public_id')); + // The hierarchy guard walks upward through this seam. + expect($fleetHelper('parentUuidOf', $foundFleet->uuid))->toBeNull(); expect($foundFleet->uuid)->toBe($fleet->uuid) ->and($fleetHelper('fleetResource', $foundFleet))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Fleet::class) ->and($fleetHelper('fleetResourceCollection', collect([$foundFleet])))->toBeInstanceOf(Illuminate\Http\Resources\Json\ResourceCollection::class) diff --git a/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php b/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php index f3cf98eb9..6b3f4519c 100644 --- a/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +++ b/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php @@ -315,7 +315,9 @@ function fleetopsApiVehicleTrackingGeofence(array $attributes = []): stdClass expect($probe->callProtected('findVehicle', ['vehicle_test']))->toBeInstanceOf(Vehicle::class) ->and($probe->callProtected('findDriver', ['driver_test']))->toBeInstanceOf(Driver::class) - ->and($probe->callProtected('getVendorUuid', ['vendors', ['public_id' => 'vendor_test']]))->toBe('vendor-1'); + // Vendor now resolves through the shared public-id resolver, which + // scopes the lookup to the session company. + ->and($probe->callProtected('resolveUuid', [Fleetbase\FleetOps\Models\Vendor::class, 'vendor_test']))->toBe('vendor-1'); expect(fn () => $probe->callProtected('findVehicle', ['missing']))->toThrow(ModelNotFoundException::class) ->and(fn () => $probe->callProtected('findDriver', ['missing']))->toThrow(ModelNotFoundException::class); diff --git a/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php b/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php index 8486efad8..d1f5f9486 100644 --- a/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php @@ -44,7 +44,7 @@ protected function queryWorkOrdersWithRequest(Request $request, callable $callba ]; } - protected function resolveModel(string $modelClass, string $id): Model + protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model { $key = $modelClass . ':' . $id; diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 361461ffa..3910c7c1d 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -290,6 +290,8 @@ protected function canUpdateDriver(): bool }); test('vehicle and fuel report requests expose core validation contracts', function () { + session(['company' => 'company-uuid']); + $vehicleRules = requestRules(CreateVehicleRequest::class); $fuelReportRules = requestRules(CreateFuelReportRequest::class); $fuelReportUpdateRules = requestRules(UpdateFuelReportRequest::class, 'PATCH'); @@ -305,6 +307,53 @@ protected function canUpdateDriver(): bool // error. These rules are what stop that. ->and($vehicleRules['odometer'])->toBe('nullable|numeric|min:0') ->and($vehicleRules['odometer_unit'])->toBe('nullable|string|max:12') + // Postman documented three statuses while the API accepted nineteen. + // This enum is the source of truth both must agree with; dropping a + // value would silently break an integration already sending it. + ->and(CreateVehicleRequest::STATUSES)->toContain( + 'active', + 'available', + 'in_use', + 'maintenance', + 'out_of_service', + 'reserved', + 'retired', + 'staging', + 'on_route', + 'idle', + 'cleaning', + 'awaiting_parts', + 'inspection_due', + 'inspection_failed', + 'accident', + 'compliance_hold', + 'stolen', + 'operational', + 'decommissioned', + ) + // Every newly exposed vehicle field carries a type-appropriate rule. + ->and($vehicleRules['year'])->toBe('nullable|integer|min:1900|max:2100') + ->and($vehicleRules['dpf_equipped'])->toBe('nullable|boolean') + ->and($vehicleRules['purchased_at'])->toBe('nullable|date') + ->and($vehicleRules['currency'])->toBe('nullable|string|size:3') + ->and($vehicleRules['specs'])->toBe('nullable|array') + ->and($vehicleRules['meta'])->toBe('nullable|array') + ->and($vehicleRules['seating_capacity'])->toBe('nullable|integer|min:0') + ->and($vehicleRules['weight'])->toBe('nullable|numeric|min:0') + ->and($vehicleRules['return_to_depot'])->toBe('nullable|boolean') + // Relationship inputs are confined to the caller's own company. + ->and(ruleStrings($vehicleRules['vendor']))->toContain('exists:vendors,public_id') + ->and(ruleStrings($vehicleRules['driver']))->toContain('exists:drivers,public_id') + ->and(ruleStrings($vehicleRules['category']))->toContain('exists:categories,public_id') + ->and(ruleStrings($vehicleRules['warranty']))->toContain('exists:warranties,public_id') + ->and($vehicleRules['vendor'][2]->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]) + ->and($vehicleRules['driver'][2]->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]) ->and($fuelReportRules['driver'])->toBe(['required']) ->and($fuelReportRules['odometer'])->toBe(['required']) ->and($fuelReportRules['volume'])->toBe(['required']) @@ -434,7 +483,24 @@ protected function canUpdateDriver(): bool ]) ->and(ruleStrings($fleetCreateRules['name']))->toContain('required') ->and(ruleStrings($fleetPatchRules['name']))->not->toContain('required') - ->and($fleetCreateRules['service_area'])->toBe('exists:service_areas,public_id') + ->and(ruleStrings($fleetCreateRules['service_area']))->toContain('nullable', 'exists:service_areas,public_id') + ->and(ruleStrings($fleetCreateRules['zone']))->toContain('exists:zones,public_id') + ->and(ruleStrings($fleetCreateRules['vendor']))->toContain('exists:vendors,public_id') + ->and(ruleStrings($fleetCreateRules['parent_fleet']))->toContain('exists:fleets,public_id') + ->and($fleetCreateRules['color'])->toBe('nullable|string|max:64') + ->and($fleetCreateRules['task'])->toBe('nullable|string|max:191') + ->and($fleetCreateRules['status'])->toBe('nullable|string|max:64') + // Every fleet relationship input is confined to the caller's own + // company, so another organization's public id cannot be assigned + // — and cannot be probed for existence either. + ->and($fleetCreateRules['service_area'][2]->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]) + ->and($fleetCreateRules['parent_fleet'][2]->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]) ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid']) ->and(requestRules(DecodeTrackingNumberQR::class))->toBe(['code' => 'required|string']) ->and(requestRules(CreateServiceQuoteRequest::class))->toBe([]); @@ -838,6 +904,9 @@ public function parameter($key, $default = null) }); test('driver request authorizes api sanctum and navigator sessions with identity rules', function () { + // The harness `session()` is a process-wide static independent of the + // bound store, and the company-scoped rule closures read it. + session(['company' => 'company-uuid']); bindFleetOpsRequestSession(); $request = CreateDriverRequest::create('/fleetops-test', 'POST', [ @@ -854,15 +923,27 @@ public function parameter($key, $default = null) expect($request->authorize())->toBeTrue() ->and(ruleStrings($createRules['name']))->toContain('required') ->and(ruleStrings($patchRules['name']))->not->toContain('required') - ->and(ruleStrings($createRules['email']))->toContain('required', 'email', 'unique:users') - ->and(ruleStrings($createRules['phone']))->toContain('required', 'unique:users') + // Contact details are optional but still validated and still unique: + // an operational driver record may legitimately have neither. + ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users') + ->and(ruleStrings($createRules['email']))->not->toContain('required') + ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users') + ->and(ruleStrings($createRules['phone']))->not->toContain('required') ->and($createRules['password'])->toBe('nullable|string') ->and($createRules['country'])->toBe('nullable|size:2') - ->and($createRules['vehicle'])->toBe('nullable|string|starts_with:vehicle_|exists:vehicles,public_id') + ->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id') ->and($createRules['license_expiry'])->toBe('nullable|date') ->and($createRules['status'])->toBe('nullable|string|in:active,available,inactive') - ->and($createRules['vendor'])->toBe('nullable|exists:vendors,public_id') - ->and($createRules['job'])->toBe('nullable|exists:orders,public_id') + ->and(ruleStrings($createRules['vendor']))->toContain('nullable', 'exists:vendors,public_id') + ->and(ruleStrings($createRules['job']))->toContain('nullable', 'exists:orders,public_id') + ->and($createRules['vehicle'][3]->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]) + ->and($createRules['internal_id'])->toBe('nullable|string|max:191') + ->and($createRules['meta'])->toBe('nullable|array') + ->and($createRules['current_status'])->toBe('nullable|string|max:64') + ->and($createRules['max_travel_time'])->toBe('nullable|integer|min:0') ->and($createRules['location'][1])->toBeInstanceOf(ResolvablePoint::class) ->and($createRules['latitude'])->toBe(['nullable', 'required_with:longitude']) ->and($createRules['longitude'])->toBe(['nullable', 'required_with:latitude']) From 86cc6c875ea2b05adba0ea858bdd4879aa07c2c2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 4 Sep 2026 19:39:51 +0800 Subject: [PATCH 02/11] Restore 100% server coverage for the expanded public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage gate caught 17 statements the new code added but no test entered. Every one is now reached by a test that asserts the behaviour, not by a call made only to move the number. - CreateFleetRequest::attributes() — asserted in RequestContractsTest, which already pins the rest of the fleet request contract. - PublicRelationNotFoundException::getRelation()/getIdentifier() — covered in ExceptionContractsTest alongside the other FleetOps exceptions, including the null-identifier case. - ResolvesPublicRelationUuids' blank-identifier early return — a filter given an empty value must resolve to nothing without reaching the database. - DriverFilter's console uuid branch — Http::isInternalRequest() reads the resolved route's uri rather than the request path, so the branch needs a request with an internal route resolver to be reachable at all. The test now builds one, which is also what proves the branch is internal-only. - FleetController: the update path's cross-company relationship rejection (the create path was already covered), removeVehicle's and removeDriver's not-found answers, and the real bodies of findVehicle, findDriver, withPublicRelations and queryFleets — the last four exercised against SQLite in FleetPublicContractTest, which asserts that the lookups are company-scoped and that the query pipeline eager loads the relations the public resource reports as public ids. Local baseline: 100.00% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes. The statement total matches the figure CI reported exactly, so the 17 closed here are precisely the ones it flagged. php scripts/pest-file-runner.php: 434 files, exit 0. --- .../tests/ApiFleetControllerContractsTest.php | 34 ++++- .../tests/ControllerFilterContractsTest.php | 3 + server/tests/DriverFilterExecutionTest.php | 48 +++++++ server/tests/ExceptionContractsTest.php | 15 ++ .../Http/Api/FleetPublicContractTest.php | 128 ++++++++++++++++++ server/tests/RequestContractsTest.php | 6 + 6 files changed, 229 insertions(+), 5 deletions(-) diff --git a/server/tests/ApiFleetControllerContractsTest.php b/server/tests/ApiFleetControllerContractsTest.php index e9fdc8e1d..00d7edb67 100644 --- a/server/tests/ApiFleetControllerContractsTest.php +++ b/server/tests/ApiFleetControllerContractsTest.php @@ -358,6 +358,11 @@ function fleetopsFleetFake(array $attributes): FleetOpsApiFleetFake session(['company' => 'company-uuid']); foreach (['parent_fleet', 'vendor', 'zone', 'service_area'] as $relation) { + $expected = [ + 'json' => ['error' => 'No ' . str_replace('_', ' ', $relation) . ' resource found for the identifier provided.'], + 'status' => 404, + ]; + $controller = new FleetOpsApiFleetControllerProbe(); $controller->unresolvable = ['other_company_id']; @@ -366,12 +371,20 @@ function fleetopsFleetFake(array $attributes): FleetOpsApiFleetFake $relation => 'other_company_id', ])); + $updateController = new FleetOpsApiFleetControllerProbe(); + $updateController->unresolvable = ['other_company_id']; + $updateController->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']); + + $updated = $updateController->update('fleet_123', fleetopsUpdateFleetRequest([ + $relation => 'other_company_id', + ])); + // A cross-company identifier is answered exactly as a missing one, so // the response cannot be used to discover another organization's data. - expect($created)->toBe([ - 'json' => ['error' => 'No ' . str_replace('_', ' ', $relation) . ' resource found for the identifier provided.'], - 'status' => 404, - ]); + // Update answers the same way create does, and writes nothing. + expect($created)->toBe($expected) + ->and($updated)->toBe($expected) + ->and($updateController->fleet->updates)->toBe([]); } }); @@ -480,6 +493,17 @@ function fleetopsFleetFake(array $attributes): FleetOpsApiFleetFake ])->and($missingResource->assignDriver('fleet_123', 'driver_other'))->toBe([ 'json' => ['error' => 'Fleet or driver resource not found.'], 'status' => 404, - ])->and($missingFleet->membershipCalls)->toBe([]) + ]) + // Removal answers the same way assignment does when either side is + // unavailable, so all four operations stay consistent. + ->and($missingResource->removeVehicle('fleet_123', 'vehicle_other'))->toBe([ + 'json' => ['error' => 'Fleet or vehicle resource not found.'], + 'status' => 404, + ]) + ->and($missingResource->removeDriver('fleet_123', 'driver_other'))->toBe([ + 'json' => ['error' => 'Fleet or driver resource not found.'], + 'status' => 404, + ]) + ->and($missingFleet->membershipCalls)->toBe([]) ->and($missingResource->membershipCalls)->toBe([]); }); diff --git a/server/tests/ControllerFilterContractsTest.php b/server/tests/ControllerFilterContractsTest.php index a4907cebd..9f34d61b4 100644 --- a/server/tests/ControllerFilterContractsTest.php +++ b/server/tests/ControllerFilterContractsTest.php @@ -607,6 +607,8 @@ public function get(string $key): ?string $filter->zone($publicIds['zones']); $filter->parentFleet($publicIds['fleets']); $filter->vendor($publicIds['vendors']); + // A blank identifier resolves to nothing without reaching the database. + $filter->vendor(''); $filter->publicId('fleet-public'); $filter->task('delivery'); $filter->name('North Fleet'); @@ -631,6 +633,7 @@ public function get(string $key): ?string ->and($query->calls)->toContain(['whereIn', 'zone_uuid', [$uuids['zones']]]) ->and($query->calls)->toContain(['whereIn', 'parent_fleet_uuid', [$uuids['fleets']]]) ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]]) + ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', []]) // `?query=` searches the fleet's own columns; it used to reach for a // `user` relation Fleet does not have. ->and(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->not->toContain('user') diff --git a/server/tests/DriverFilterExecutionTest.php b/server/tests/DriverFilterExecutionTest.php index d8f58cc78..ea14a9713 100644 --- a/server/tests/DriverFilterExecutionTest.php +++ b/server/tests/DriverFilterExecutionTest.php @@ -94,6 +94,42 @@ private function invokeNested($value): void } } +/** + * A filter whose request resolves to an internal console route. + * + * `Http::isInternalRequest()` reads the resolved route's uri, not the request + * path, so a filter built without a route resolver is always public — which is + * why the console's uuid branch needs a route to be reached at all. + */ +function fleetopsInternalDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder): DriverFilter +{ + $uri = 'int/v1/fleet-ops/drivers'; + $request = Request::create('/' . $uri, 'GET'); + $session = app('session.store'); + $session->put('company', 'company_test'); + $request->setLaravelSession($session); + $request->setRouteResolver(fn () => new class($uri) { + public array $action = []; + + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } + }); + + $filter = new DriverFilter($request); + $reflection = new ReflectionClass($filter); + $property = $reflection->getParentClass()->getProperty('builder'); + $property->setAccessible(true); + $property->setValue($filter, $builder); + + return $filter; +} + function fleetopsDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder, array $query = []): DriverFilter { $request = Request::create('/int/v1/drivers', 'GET', $query); @@ -337,3 +373,15 @@ public function reverseQuery($query) expect($addressBuilder->called('distanceSphere'))->toBeTrue() ->and($addressBuilder->called('distanceSphereValue'))->toBeTrue(); }); + +test('driver filter keeps the console uuid branch for internal requests only', function () { + $uuid = (string) Str::uuid(); + + $internalBuilder = new FleetOpsRecordingDriverFilterBuilder(); + fleetopsInternalDriverFilter($internalBuilder)->vehicle($uuid); + + // The console sends a vehicle uuid and must keep matching on it directly, + // without a lookup. + expect(collect($internalBuilder->methodCalls('where'))->map(fn ($call) => [$call[1], $call[2]])->all()) + ->toContain(['vehicle_uuid', [$uuid]]); +}); diff --git a/server/tests/ExceptionContractsTest.php b/server/tests/ExceptionContractsTest.php index 038622474..cf3401dd2 100644 --- a/server/tests/ExceptionContractsTest.php +++ b/server/tests/ExceptionContractsTest.php @@ -11,6 +11,7 @@ class User extends \Illuminate\Database\Eloquent\Model namespace { use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; use Fleetbase\FleetOps\Exceptions\IntegratedVendorException; + use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Exceptions\TelematicProviderException; use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; @@ -78,4 +79,18 @@ class User extends \Illuminate\Database\Eloquent\Model 'integratedVendorId' => 'vendor-1', ]); }); + + test('public relation exceptions name the input that failed to resolve', function () { + $previous = new Illuminate\Database\Eloquent\ModelNotFoundException(); + $exception = new PublicRelationNotFoundException('parent_fleet', 'fleet_other_company', $previous); + + // The message names the field in prose, so a client is told which input + // was at fault — while a cross-company id and a missing one produce the + // same answer, so neither can be used to probe another organization. + expect($exception->getMessage())->toBe('No parent fleet resource found for the identifier provided.') + ->and($exception->getRelation())->toBe('parent_fleet') + ->and($exception->getIdentifier())->toBe('fleet_other_company') + ->and($exception->getPrevious())->toBe($previous) + ->and((new PublicRelationNotFoundException('vendor'))->getIdentifier())->toBeNull(); + }); } diff --git a/server/tests/Feature/Http/Api/FleetPublicContractTest.php b/server/tests/Feature/Http/Api/FleetPublicContractTest.php index 5d9292f8b..1fea58ea9 100644 --- a/server/tests/Feature/Http/Api/FleetPublicContractTest.php +++ b/server/tests/Feature/Http/Api/FleetPublicContractTest.php @@ -5,6 +5,32 @@ use Fleetbase\FleetOps\Models\Fleet; use Illuminate\Http\Request; +// The query pipeline reaches Fleetbase\Support helpers that call the framework's +// auth() and session() helpers; neither exists in the bare-container harness. +if (!function_exists('Fleetbase\Support\auth')) { + eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }'); +} + +if (!function_exists('Fleetbase\Support\session')) { + 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); }'); +} + +if (!Request::hasMacro('getController')) { + Request::macro('getController', fn () => new FleetController()); +} + +if (!Request::hasMacro('or')) { + Request::macro('or', function (array $params = [], $default = null) { + foreach ($params as $param) { + if ($this->has($param)) { + return $this->input($param); + } + } + + return $default; + }); +} + class FleetOpsFleetResourceRouteFixture { public array $action = []; @@ -17,6 +43,26 @@ public function uri(): string { return $this->uri; } + + public function getAction($key = null): string + { + return FleetController::class . '@query'; + } + + public function getActionMethod(): string + { + return 'query'; + } + + public function getName(): string + { + return 'api.v1.fleets.query'; + } + + public function parameters(): array + { + return []; + } } function fleetopsFleetResourceRequest(bool $internal, array $query = []): Request @@ -59,6 +105,7 @@ public function __call($method, $arguments) return $this->c->{$method}(...$arguments); } }); + app()->instance('db.schema', $connection->getSchemaBuilder()); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); $schema = $connection->getSchemaBuilder(); @@ -109,6 +156,26 @@ public function __call($method, $arguments) }); } + // Permission directives are consulted by the query pipeline. + $schema->create('directives', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'company_uuid', 'permission_uuid', 'subject_type', 'subject_uuid', 'key', 'rules'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + // The fleet image relation is eager loaded alongside the others. + $schema->create('files', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'disk', 'path', 'bucket', 'type', 'original_filename'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $schema->create('custom_field_values', function ($blueprint) { $blueprint->increments('id'); foreach (['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'] as $column) { @@ -230,3 +297,64 @@ function fleetopsFleetResourceFixture(array $overrides = []): Fleet expect(method_exists(FleetController::class, $method))->toBeTrue(); } }); + +test('the fleet controller lookup query and eager-load helpers run their real bodies', function () { + $connection = fleetopsFleetResourceDatabase(); + session(['company' => 'company-uuid']); + + $connection->table('fleets')->insert([ + ['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123', 'company_uuid' => 'company-uuid', 'name' => 'Haulers', 'service_area_uuid' => 'service-area-uuid', 'parent_fleet_uuid' => 'parent-uuid'], + ['uuid' => 'other-company-fleet', 'public_id' => 'fleet_elsewhere', 'company_uuid' => 'another-company', 'name' => 'Theirs', 'service_area_uuid' => null, 'parent_fleet_uuid' => null], + ]); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-uuid', 'public_id' => 'vehicle_123', 'company_uuid' => 'company-uuid']); + $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_123', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']); + + $controller = new FleetController(); + $call = function (string $method, ...$arguments) use ($controller) { + $reflection = new ReflectionMethod(FleetController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($controller, ...$arguments); + }; + + $fleet = $call('findFleet', 'fleet_123'); + + expect($fleet->uuid)->toBe('fleet-uuid') + ->and($call('findVehicle', 'vehicle_123')->uuid)->toBe('vehicle-uuid') + ->and($call('findDriver', 'driver_123')->uuid)->toBe('driver-uuid'); + + // A fleet in another company is unavailable, not forbidden. + expect(fn () => $call('findFleet', 'fleet_elsewhere')) + ->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); + + // The relations the public resource reports as public ids are eager loaded + // rather than resolved one query at a time. + $loaded = $call('withPublicRelations', $fleet); + + expect($loaded->relationLoaded('serviceArea'))->toBeTrue() + ->and($loaded->relationLoaded('zone'))->toBeTrue() + ->and($loaded->relationLoaded('vendor'))->toBeTrue() + ->and($loaded->relationLoaded('parentFleet'))->toBeTrue() + ->and($loaded->relationLoaded('photo'))->toBeTrue() + ->and($loaded->serviceArea->public_id)->toBe('service_area_123'); + + // The query pipeline scopes to the caller's company and eager loads the + // same relations for every row in the page. + $uri = 'v1/fleets'; + $request = Request::create('/' . $uri, 'GET'); + $store = app('session.store'); + $store->put('company', 'company-uuid'); + $request->setLaravelSession($store); + $request->setRouteResolver(fn () => new FleetOpsFleetResourceRouteFixture($uri)); + app()->instance('request', $request); + + $results = $call('queryFleets', $request); + + $returned = $results->pluck('uuid')->all(); + + expect($returned)->toContain('fleet-uuid', 'parent-uuid') + ->and($returned)->not->toContain('other-company-fleet') + ->and($results->first()->relationLoaded('serviceArea'))->toBeTrue() + ->and($results->first()->relationLoaded('parentFleet'))->toBeTrue(); +}); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 3910c7c1d..075f0efdb 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -501,6 +501,12 @@ protected function canUpdateDriver(): bool ['where', 'company_uuid', 'company-uuid'], ['whereNull', 'deleted_at'], ]) + // A validation message naming `parent_fleet` reads worse than one + // naming "parent fleet". + ->and(CreateFleetRequest::create('/fleetops-test', 'POST')->attributes())->toBe([ + 'service_area' => 'service area', + 'parent_fleet' => 'parent fleet', + ]) ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid']) ->and(requestRules(DecodeTrackingNumberQR::class))->toBe(['code' => 'required|string']) ->and(requestRules(CreateServiceQuoteRequest::class))->toBe([]); From 4c72cf689f1ad78805cff0e1cf0075e8bd97de74 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 15:37:41 +0800 Subject: [PATCH 03/11] Make the relationship contract additive, and enforce membership uniqueness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the response design from the first revision of this branch and closes the remaining gaps. Relationships are two keys, never one key with two types - The previous revision returned a public id *under* the object key — so `vendor` was an object when expanded and a string otherwise. The SDK stores what the API returns verbatim (`Resource::$attributes = $attributes`, no normalisation), so a property that changes type between calls breaks every consumer that dereferences it. Navigator interpolates `driver.user` straight into a socket channel name; an object there subscribes it to `user.[object Object]` and quietly stops delivering messages. - Each relationship now has an always-present `_id` public identifier and, separately, the nested `` object with exactly the shape the base branch returned: absent unless loaded, an object when loaded, never a string. Fleet gains service_area_id, zone_id, vendor_id, parent_fleet_id, photo_id; Vehicle gains driver_id, vendor_id, category_id, warranty_id, photo_id. No key was removed from any resource — the diff against the base is additive on all three. - Driver keeps `user` and `company` as the public-id strings they have always been, keeps `company_name`, and keeps `vehicle`, `vendor` and `current_job` as objects on create, update and retrieve, which still load them without a `with`. Expansion - `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand` alias all normalise to one list. Names are mapped through an explicit per-resource allowlist, so nothing user-controlled reaches `load()` — Core hands `with` straight to Eloquent, where an unknown name is a 500 for what is only a typo. Unsupported names are ignored. - `?with=subfleets` finally resolves. The public name and the relation differ only in case, and because PHP method calls are case-insensitive the old `load('subfleets')` stored a second copy of the relation under a mis-cased key that `whenLoaded('subFleets')` could not see. Nested `subfleets.drivers` / `subfleets.vehicles` work explicitly, and the implicit nesting the released contract had is preserved. Membership uniqueness is now the database's job - A migration adds composite unique indexes on fleet_vehicles(fleet_uuid, vehicle_uuid) and fleet_drivers(fleet_uuid, driver_uuid), collapsing existing duplicates first: an active row wins over a tombstone, the lowest id wins among equals, and a pair that is entirely soft-deleted keeps one restorable row. Only redundant pivot rows are removed. - Soft-deleted rows stay inside the key, deliberately — a removed membership is restored on re-assignment rather than replaced, so its key must stay taken. - The assignment path catches only the duplicate-key violation, adopts the winner's row and answers successfully. Any other violation is rethrown rather than reported as a successful assignment. internal_id and public_id - Exact on the public API, partial in the console, chosen by the resolved route. An importer asking whether VEH-10 exists must not be told yes because VEH-100 does; the console's search box must keep finding it. VIN and plate number are untouched. Driver write/read parity - `timezone` is copied to the linked user on update. It was accepted, documented, answered 200 — and dropped, because the update copied only name, email and phone and the driver record has no timezone column. - Email and phone uniqueness is now enforced on update against every other live user, ignoring the driver's own by uuid, so an unchanged value still succeeds. Also fixed: `assignedOrdersCount()` and `currentOrderReference()` ran a query on every public Vehicle and Driver response and discarded the result, because `when()` evaluates a plain value argument eagerly. Validation: php scripts/pest-file-runner.php — 437 files, exit 0. Coverage 100.00% on all three metrics (34784/34784 statements, 4441/4441 methods, 531/531 classes). SDK and Navigator contract compatibility verified against the untouched checkouts; neither was modified. --- ...que_indexes_to_fleet_membership_pivots.php | 129 ++++++ .../v1/Concerns/ResolvesPublicExpansions.php | 187 ++++++++ .../Controllers/Api/v1/DriverController.php | 28 +- .../Controllers/Api/v1/FleetController.php | 121 ++++- .../Controllers/Api/v1/VehicleController.php | 48 +- server/src/Http/Filter/DriverFilter.php | 34 +- server/src/Http/Filter/FleetFilter.php | 14 +- server/src/Http/Filter/VehicleFilter.php | 35 +- .../src/Http/Requests/CreateDriverRequest.php | 53 ++- .../Concerns/ResolvesPublicRelationFields.php | 77 +++- server/src/Http/Resources/v1/Driver.php | 4 +- server/src/Http/Resources/v1/Fleet.php | 36 +- server/src/Http/Resources/v1/Vehicle.php | 31 +- .../ApiDriverControllerContractsTest.php | 69 +++ .../tests/ControllerFilterContractsTest.php | 9 +- .../Http/Api/FleetMembershipMigrationTest.php | 140 ++++++ .../Feature/Http/Api/FleetMembershipTest.php | 129 +++++- .../Http/Api/FleetPublicContractTest.php | 153 ++++-- .../Http/Api/PublicIdentifierFilterTest.php | 293 ++++++++++++ .../Http/Api/VehiclePublicContractTest.php | 434 ++++++++++++++++++ server/tests/FleetHierarchyResourceTest.php | 64 ++- .../Unit/Http/Resources/FleetResourceTest.php | 56 ++- 22 files changed, 2012 insertions(+), 132 deletions(-) create mode 100644 server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php create mode 100644 server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php create mode 100644 server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php create mode 100644 server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php create mode 100644 server/tests/Feature/Http/Api/VehiclePublicContractTest.php diff --git a/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php b/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php new file mode 100644 index 000000000..f8f1ffad0 --- /dev/null +++ b/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php @@ -0,0 +1,129 @@ + + */ + private array $pivots = [ + 'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'], + 'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'], + ]; + + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + foreach ($this->pivots as $table => [$memberColumn, $indexName]) { + if (!Schema::hasTable($table)) { + continue; + } + + $this->removeDuplicateMemberships($table, $memberColumn); + + if (!$this->indexExists($table, $indexName)) { + Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) { + $blueprint->unique(['fleet_uuid', $memberColumn], $indexName); + }); + } + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach ($this->pivots as $table => [$memberColumn, $indexName]) { + if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) { + Schema::table($table, function (Blueprint $blueprint) use ($indexName) { + $blueprint->dropUnique($indexName); + }); + } + } + } + + /** + * Collapse every duplicated pair down to the one row worth keeping. + * + * An active row wins over a tombstone, because that is the membership the + * fleet actually has today. Among equals the lowest id wins, so the outcome + * is deterministic and a re-run is a no-op. When every row for a pair is + * soft-deleted, one is still kept — removing them all would turn a later + * re-assignment into a new row and lose the original membership's history. + * + * Only redundant pivot rows are removed. No fleet, vehicle or driver is + * touched, and no surviving membership changes state. + */ + private function removeDuplicateMemberships(string $table, string $memberColumn): void + { + $duplicatePairs = DB::table($table) + ->select('fleet_uuid', $memberColumn) + ->whereNotNull('fleet_uuid') + ->whereNotNull($memberColumn) + ->groupBy('fleet_uuid', $memberColumn) + ->havingRaw('COUNT(*) > 1') + ->get(); + + foreach ($duplicatePairs as $pair) { + $pair = (array) $pair; + + $rows = DB::table($table) + ->where('fleet_uuid', $pair['fleet_uuid']) + ->where($memberColumn, $pair[$memberColumn]) + // Active rows first, then oldest first. + ->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('id') + ->pluck('id'); + + $redundant = $rows->slice(1)->values(); + + if ($redundant->isNotEmpty()) { + DB::table($table)->whereIn('id', $redundant->all())->delete(); + } + } + } + + private function indexExists(string $table, string $index): bool + { + $database = DB::connection()->getDatabaseName(); + + return DB::table('information_schema.statistics') + ->where('table_schema', $database) + ->where('table_name', $table) + ->where('index_name', $index) + ->exists(); + } +}; diff --git a/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php new file mode 100644 index 000000000..bf6605c57 --- /dev/null +++ b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php @@ -0,0 +1,187 @@ +load(...)`, with no allowlist and no mapping. Two things + * follow from that. A caller can name any string and reach `load()`, and an + * unknown one raises `RelationNotFoundException` — a 500 for a typo. And the + * public name is not always the relation name: `?with=subfleets` normalises to + * `subfleets`, while the relation is `subFleets`, so the documented expansion + * has never worked. + * + * Rewriting the request once, up front, fixes both at the only point where the + * query builder and the resource are guaranteed to agree on the list. + */ +trait ResolvesPublicExpansions +{ + /** + * Resolve the requested expansions and rewrite the request with them. + * + * @param array $allowed public name => Eloquent relation name + * + * @return array the resolved relation names, in request order + */ + protected function applyPublicExpansions(Request $request, array $allowed): array + { + $resolved = $this->resolvePublicExpansions($request, $allowed); + + // Both keys are rewritten: Core reads `with` or `expand`, whichever is + // present, so leaving the other one holding raw input would put the + // unmapped name back in front of load(). + $request->merge(['with' => $resolved]); + + if ($request->exists('expand')) { + $request->merge(['expand' => $resolved]); + } + + return $resolved; + } + + /** + * @param array $allowed + * + * @return array + */ + protected function resolvePublicExpansions(Request $request, array $allowed): array + { + $requested = $this->publicExpansionInput($request); + $resolved = []; + + foreach ($requested as $relation) { + $mapped = $this->mapPublicExpansion($relation, $allowed); + + // An unsupported expansion is ignored rather than rejected. A 422 + // would turn a harmless unknown name into a failed request for + // every generated client that sends a relation this version does + // not have yet, and the alternative — passing it through — is a + // 500 from Eloquent. + if ($mapped !== null && !in_array($mapped, $resolved, true)) { + $resolved[] = $mapped; + } + } + + return $resolved; + } + + /** + * Every accepted input shape flattened to one list of trimmed, non-empty paths. + * + * `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand` + * alias of each all arrive here and leave identical. + * + * @return array + */ + protected function publicExpansionInput(Request $request): array + { + $raw = $request->input('with'); + + if ($raw === null || $raw === '' || $raw === []) { + $raw = $request->input('expand'); + } + + if ($raw === null) { + return []; + } + + $values = []; + foreach (is_array($raw) ? $raw : [$raw] as $entry) { + if (is_array($entry)) { + continue; + } + + foreach (explode(',', (string) $entry) as $part) { + $part = trim($part); + + if ($part !== '') { + $values[] = $part; + } + } + } + + return $values; + } + + /** + * Map one public path onto its Eloquent relation path, or null if unsupported. + * + * Each dotted segment is resolved against the allowlist for the level it sits + * at, so `subfleets.drivers` is checked as `subfleets` then as `drivers` — + * a nested path cannot smuggle in a relation the top level would refuse. + * + * @param array $allowed + */ + protected function mapPublicExpansion(string $relation, array $allowed): ?string + { + $segments = explode('.', $relation); + $mapped = []; + + foreach ($segments as $index => $segment) { + $segment = trim($segment); + + if ($segment === '') { + return null; + } + + $candidates = $index === 0 ? $allowed : $this->nestedExpansionAllowList(); + $key = $this->matchExpansionKey($segment, $candidates); + + if ($key === null) { + return null; + } + + $mapped[] = $candidates[$key]; + } + + return implode('.', $mapped); + } + + /** + * Relations reachable underneath an already-allowed relation. + * + * Deliberately shallow. Subfleets carry drivers and vehicles, which is the + * documented nested case; nothing here re-opens the tree, so + * `subfleets.subfleets` is refused and an expansion cannot recurse. + * + * @return array + */ + protected function nestedExpansionAllowList(): array + { + return [ + 'drivers' => 'drivers', + 'vehicles' => 'vehicles', + ]; + } + + /** + * Match a requested segment against the allowlist, spelling-insensitively. + * + * `service_area`, `serviceArea` and `ServiceArea` all name the same relation, + * and so do `subfleets`, `subFleets` and `sub_fleets`. Comparing on + * case-folded, separator-stripped forms accepts every spelling a client or a + * generated SDK might produce without widening what is actually allowed. + * + * @param array $candidates + */ + private function matchExpansionKey(string $segment, array $candidates): ?string + { + foreach ($candidates as $key => $relation) { + if ($this->expansionLookupKey($key) === $this->expansionLookupKey($segment)) { + return $key; + } + } + + return null; + } + + private function expansionLookupKey(string $value): string + { + return strtolower(str_replace(['_', '-'], '', $value)); + } +} diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index a902b4833..a51027bf5 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Events\VehicleLocationChanged; use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest; use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest; use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest; @@ -44,6 +45,22 @@ class DriverController extends Controller { use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; use ResolvesFleetOpsApiResources; + use ResolvesPublicExpansions; + + /** + * Public expansion name => Eloquent relation name. + * + * `user` and `company` are deliberately absent. Both are already published + * as public-id strings, and Navigator interpolates `driver.user` directly + * into a socket channel name — expanding either would retype a released + * field and break it silently. + */ + public const EXPANDABLE = [ + 'vehicle' => 'vehicle', + 'vendor' => 'vendor', + 'current_job' => 'currentJob', + 'fleets' => 'fleets', + ]; /** * Creates a new Fleetbase Driver resource. @@ -52,6 +69,8 @@ class DriverController extends Controller */ public function create(CreateDriverRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // get user details for driver $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']); @@ -141,6 +160,8 @@ public function create(CreateDriverRequest $request) */ public function update($id, UpdateDriverRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the driver try { $driver = $this->findDriver($id, ['user']); @@ -167,7 +188,10 @@ public function update($id, UpdateDriverRequest $request) * password. Changing a password is its own operation with its own * proof: see changePassword(), forgotPassword() and resetPassword(). */ - $userDetails = $request->only(['name', 'email', 'phone']); + // `timezone` is accepted and documented on update, but was never copied + // to the linked user — the request validated, answered 200, and dropped + // it. The driver's own record has no timezone column; the user's does. + $userDetails = $request->only(['name', 'email', 'phone', 'timezone']); // update driver user details $driverUser = $driver->getUser(); @@ -208,6 +232,8 @@ public function update($id, UpdateDriverRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryDrivers($request); return $this->driverResourceCollection($results); diff --git a/server/src/Http/Controllers/Api/v1/FleetController.php b/server/src/Http/Controllers/Api/v1/FleetController.php index 01f313499..16e7a0594 100644 --- a/server/src/Http/Controllers/Api/v1/FleetController.php +++ b/server/src/Http/Controllers/Api/v1/FleetController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; use Fleetbase\FleetOps\Http\Requests\CreateFleetRequest; use Fleetbase\FleetOps\Http\Requests\UpdateFleetRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; @@ -19,18 +20,46 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\File; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; class FleetController extends Controller { use ResolvesFleetOpsApiResources; + // Aliased so the fleet-specific override can still reach the shared + // resolution before adding the implicit subfleet nesting on top. + use ResolvesPublicExpansions { + resolvePublicExpansions as protected resolveAllowedExpansions; + } /** - * Relationships that are eager loaded so the public resource can report each + * Relationships eager loaded so the public resource can report each * assignment as a public id without issuing a query per fleet. + * + * Loading them does not expand them: the resource returns the nested object + * only for a relation the caller named in `with`, which is the shape the + * endpoint has always had. */ protected const PUBLIC_RELATIONS = ['serviceArea', 'zone', 'vendor', 'parentFleet', 'photo']; + /** + * Public expansion name => Eloquent relation name. + * + * `subfleets` is the reason this map exists rather than a bare list: the + * public name and the relation differ only in case, and the automatic + * camelCase normalisation upstream turns `subfleets` into `subfleets`. + */ + public const EXPANDABLE = [ + 'service_area' => 'serviceArea', + 'zone' => 'zone', + 'vendor' => 'vendor', + 'parent_fleet' => 'parentFleet', + 'photo' => 'photo', + 'subfleets' => 'subFleets', + 'drivers' => 'drivers', + 'vehicles' => 'vehicles', + ]; + /** * Creates a new Fleetbase Fleet resource. * @@ -38,6 +67,8 @@ class FleetController extends Controller */ public function create(CreateFleetRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + try { $input = $this->fleetInputFromRequest($request); } catch (PublicRelationNotFoundException $exception) { @@ -63,6 +94,8 @@ public function create(CreateFleetRequest $request) */ public function update($id, UpdateFleetRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the fleet try { $fleet = $this->findFleet($id); @@ -101,6 +134,8 @@ public function update($id, UpdateFleetRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryFleets($request); return $this->fleetResourceCollection($results); @@ -113,6 +148,8 @@ public function query(Request $request) */ public function find($id, Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the fleet try { $fleet = $this->findFleet($id); @@ -241,6 +278,38 @@ public function removeDriver(string $id, string $driverId) return $this->jsonResponse($this->driverMembershipPayload($fleet, $driver, false), 200); } + /** + * Preserve the implicit subfleet nesting the released contract had. + * + * `?with[]=subfleets&with[]=drivers` used to load the subfleets' drivers as + * well as the fleet's own, so the nested collections were part of that + * response. The explicit `?with[]=subfleets.drivers` form is the better + * spelling, but dropping the implicit one would remove data a caller is + * already receiving. + * + * @param array $allowed + * + * @return array + */ + protected function resolvePublicExpansions(Request $request, array $allowed): array + { + $resolved = $this->resolveAllowedExpansions($request, $allowed); + + if (!in_array('subFleets', $resolved, true)) { + return $resolved; + } + + foreach (['drivers', 'vehicles'] as $nested) { + $path = 'subFleets.' . $nested; + + if (in_array($nested, $resolved, true) && !in_array($path, $resolved, true)) { + $resolved[] = $path; + } + } + + return $resolved; + } + /** * The explicit public input allowlist for a fleet. * @@ -313,10 +382,28 @@ protected function withPublicRelations(Fleet $fleet): Fleet protected function assignVehicleToFleet(Fleet $fleet, Vehicle $vehicle): void { - $membership = FleetVehicle::withTrashed()->firstOrNew([ + $this->assignMembership(FleetVehicle::class, [ 'fleet_uuid' => $fleet->uuid, 'vehicle_uuid' => $vehicle->uuid, ]); + } + + /** + * Create or restore exactly one membership row for a pivot pair. + * + * Read-then-write is idempotent only against itself. Two requests arriving + * together both see no membership and both insert, which is precisely what + * an importer retrying a timed-out call produces. The composite unique index + * is the actual guarantee; this catches the violation the loser gets and + * finishes the winner's work, so both callers still receive the same + * successful answer. + * + * @param class-string $pivotClass + * @param array $attributes + */ + protected function assignMembership(string $pivotClass, array $attributes): void + { + $membership = $pivotClass::withTrashed()->firstOrNew($attributes); if ($membership->trashed()) { $membership->restore(); @@ -324,8 +411,24 @@ protected function assignVehicleToFleet(Fleet $fleet, Vehicle $vehicle): void return; } - if (!$membership->exists) { + if ($membership->exists) { + return; + } + + try { $membership->save(); + } catch (UniqueConstraintViolationException $exception) { + // Only this violation is swallowed. Anything else is a real failure + // and must not be reported as a successful assignment. + $winner = $pivotClass::withTrashed()->where($attributes)->first(); + + if (!$winner) { + throw $exception; + } + + if ($winner->trashed()) { + $winner->restore(); + } } } @@ -339,20 +442,10 @@ protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void { - $membership = FleetDriver::withTrashed()->firstOrNew([ + $this->assignMembership(FleetDriver::class, [ 'fleet_uuid' => $fleet->uuid, 'driver_uuid' => $driver->uuid, ]); - - if ($membership->trashed()) { - $membership->restore(); - - return; - } - - if (!$membership->exists) { - $membership->save(); - } } protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 0d0b9dc02..2cf1a07f6 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Events\VehicleLocationChanged; use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; +use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest; use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; @@ -29,12 +30,29 @@ class VehicleController extends Controller { use ResolvesFleetOpsApiResources; + use ResolvesPublicExpansions; /** * Relationships eager loaded so the public resource can report each * assignment as a public id without a query per vehicle. + * + * Loading them does not expand them: the nested object is returned only for + * a relation the caller named in `with`, which is the shape the endpoint has + * always had. + */ + protected const PUBLIC_RELATIONS = ['vendor', 'category', 'warranty', 'driver', 'photo']; + + /** + * Public expansion name => Eloquent relation name. */ - protected const PUBLIC_RELATIONS = ['vendor', 'category', 'warranty', 'driver']; + public const EXPANDABLE = [ + 'driver' => 'driver', + 'vendor' => 'vendor', + 'category' => 'category', + 'warranty' => 'warranty', + 'photo' => 'photo', + 'devices' => 'devices', + ]; /** * Creates a new Fleetbase Vehicle resource. @@ -45,6 +63,8 @@ class VehicleController extends Controller */ public function create(CreateVehicleRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // get request input try { $input = $this->vehicleInputFromRequest($request); @@ -95,6 +115,8 @@ public function create(CreateVehicleRequest $request) */ public function update($id, UpdateVehicleRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the vehicle try { $vehicle = $this->findVehicle($id); @@ -160,6 +182,8 @@ public function update($id, UpdateVehicleRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryVehicles($request); return $this->vehicleResourceCollection($results); @@ -172,8 +196,12 @@ public function query(Request $request) * * @return \Fleetbase\Http\Resources\VehicleCollection */ - public function find($id) + public function find($id, ?Request $request = null) { + if ($request instanceof Request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + } + // find for the vehicle try { $vehicle = $this->findVehicle($id); @@ -473,7 +501,21 @@ protected function queryVehicles(Request $request) protected function vehicleResource(Vehicle $vehicle) { - return new VehicleResource($vehicle); + return new VehicleResource($this->withPublicRelations($vehicle)); + } + + /** + * Load the relations the public resource reports as identifiers. + * + * One query per relation instead of one per relation per read, and it makes + * an assignment made moments earlier in the same request readable back + * immediately. + */ + protected function withPublicRelations(Vehicle $vehicle): Vehicle + { + $vehicle->loadMissing(static::PUBLIC_RELATIONS); + + return $vehicle; } protected function vehicleResourceCollection($results) diff --git a/server/src/Http/Filter/DriverFilter.php b/server/src/Http/Filter/DriverFilter.php index 91c311104..5aa390e50 100644 --- a/server/src/Http/Filter/DriverFilter.php +++ b/server/src/Http/Filter/DriverFilter.php @@ -50,9 +50,28 @@ function ($query) use ($searchQuery) { }); } + /** + * Match a driver by the identifier the operator's own system uses. + * + * Two callers, two meanings. The Fleet-Ops console types into a search box + * and expects `VEH-10` to find `VEH-100`; an importer asks whether `VEH-10` + * exists and must not be told yes because `VEH-100` does. A partial match + * there produces a duplicate on every rerun. + * + * Core's ordinary fillable-column filtering is already equality — this + * method exists only because the console needs the looser behaviour, so it + * is the console that gets the exception. An unknown request context is + * treated as public: exact is the safe default of the two. + */ public function internalId(?string $internalId) { - $this->builder->searchWhere('internal_id', $internalId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('internal_id', $internalId); + + return; + } + + $this->builder->where('internal_id', '=', $internalId); } public function name(?string $name) @@ -65,9 +84,20 @@ function ($query) use ($name) { ); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicId) { - $this->builder->searchWhere('public_id', $publicId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicId); + + return; + } + + $this->builder->where('public_id', '=', $publicId); } public function facilitator(string $facilitator) diff --git a/server/src/Http/Filter/FleetFilter.php b/server/src/Http/Filter/FleetFilter.php index 077fa4472..169bdc31d 100644 --- a/server/src/Http/Filter/FleetFilter.php +++ b/server/src/Http/Filter/FleetFilter.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\Zone; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; +use Fleetbase\Support\Http; class FleetFilter extends Filter { @@ -65,9 +66,20 @@ public function vendor(?string $vendor) $this->builder->whereIn('vendor_uuid', $this->resolvePublicRelationUuids(Vendor::class, $vendor)); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicId) { - $this->builder->searchWhere('public_id', $publicId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicId); + + return; + } + + $this->builder->where('public_id', '=', $publicId); } public function task(?string $task) diff --git a/server/src/Http/Filter/VehicleFilter.php b/server/src/Http/Filter/VehicleFilter.php index 90392933a..9257b7a2e 100644 --- a/server/src/Http/Filter/VehicleFilter.php +++ b/server/src/Http/Filter/VehicleFilter.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; +use Fleetbase\Support\Http; class VehicleFilter extends Filter { @@ -41,9 +42,28 @@ public function display_name(?string $display_name) * vehicle already exists, and it was the one identifier the filter did not * support — so every lookup fell through to a create. */ + /** + * Match a vehicle by the identifier the operator's own system uses. + * + * Two callers, two meanings. The Fleet-Ops console types into a search box + * and expects `VEH-10` to find `VEH-100`; an importer asks whether `VEH-10` + * exists and must not be told yes because `VEH-100` does. A partial match + * there produces a duplicate on every rerun. + * + * Core's ordinary fillable-column filtering is already equality — this + * method exists only because the console needs the looser behaviour, so it + * is the console that gets the exception. An unknown request context is + * treated as public: exact is the safe default of the two. + */ public function internalId(?string $internalId) { - $this->builder->searchWhere('internal_id', $internalId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('internal_id', $internalId); + + return; + } + + $this->builder->where('internal_id', '=', $internalId); } public function vin(?string $vin) @@ -51,9 +71,20 @@ public function vin(?string $vin) $this->builder->searchWhere('vin', $vin); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicIc) { - $this->builder->searchWhere('public_id', $publicIc); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicIc); + + return; + } + + $this->builder->where('public_id', '=', $publicIc); } public function plateNumber(?string $plateNumber) diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 11c789f1d..3bc95d57f 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Http\Requests; use Fleetbase\FleetOps\Http\Requests\Concerns\ScopesPublicRelationRules; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Rules\ResolvablePoint; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; @@ -41,8 +42,8 @@ public function rules(): array * are supplied; a driver created without them simply cannot sign in * to Navigator until credentials are added. */ - 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'phone' => ['nullable', 'string', Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), $this->uniqueAmongUsers()], + 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()], 'password' => 'nullable|string', 'timezone' => 'nullable|string|max:64', @@ -83,6 +84,54 @@ public function rules(): array ]; } + /** + * Uniqueness against every other live user account. + * + * Update used to skip this check entirely, so one driver could be given an + * address another driver already signs in with — and the two would then be + * indistinguishable to every identity lookup, including the password reset + * that matches on it. The driver's own linked user is ignored by uuid, so + * resending an unchanged address or number still succeeds. + * + * No return type: the pest harness substitutes its own + * `Illuminate\Validation\Rule`, which is not an instance of `Rules\Unique`. + */ + protected function uniqueAmongUsers() + { + $rule = Rule::unique('users')->whereNull('deleted_at'); + + $ownUserUuid = $this->linkedUserUuid(); + + return $ownUserUuid ? $rule->ignore($ownUserUuid, 'uuid') : $rule; + } + + /** + * The uuid of the user behind the driver this request is updating. + * + * Null on create, and null when there is no resolvable route parameter — + * which is also what keeps this from querying during rule construction in + * contexts that have no route bound. + */ + protected function linkedUserUuid(): ?string + { + if ($this->isMethod('POST')) { + return null; + } + + $id = $this->route('id'); + + if (!is_string($id) || $id === '') { + return null; + } + + return Driver::withoutGlobalScopes() + ->where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('internal_id', $id); + }) + ->value('user_uuid'); + } + /** * Get custom attributes for validator errors. */ diff --git a/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php index b59453064..14913dce5 100644 --- a/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +++ b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php @@ -3,21 +3,27 @@ namespace Fleetbase\FleetOps\Http\Resources\v1\Concerns; use Fleetbase\Support\Http; -use Illuminate\Support\Str; +use Illuminate\Http\Resources\MissingValue; /** - * Renders a relationship for the public API as the related resource's public id. + * Renders a relationship twice: as an additive `_id` public identifier, and + * as the nested object under the name the endpoint has always used. * - * A caller that writes `vendor: "vendor_abc"` needs to read the assignment back - * to confirm it persisted, and the public contract never exposes the uuid the - * column actually holds. Asking for the relation through `?with=` still returns - * the full nested object, and internal console requests keep the exact - * `whenLoaded` shape they have always had. + * The two are deliberately separate keys. The SDK stores whatever the API returns + * verbatim — `Resource::$attributes = $attributes`, no normalisation — so a + * property that is an object on one call and a string on another silently breaks + * every consumer that dereferences it. Navigator interpolates `driver.user` + * straight into a socket channel name; an object there would subscribe it to + * `user.[object Object]` and simply stop delivering messages. So an existing + * object key stays an object key, always, and the identifier arrives beside it. */ trait ResolvesPublicRelationFields { /** - * The relations the caller explicitly asked for, camelCased. + * The relations the caller asked for, as real Eloquent relation names. + * + * The controller has already normalised, mapped and allowlisted `with` / + * `expand` by the time a resource runs, so this is a plain read. * * @return array */ @@ -25,38 +31,51 @@ protected function requestedRelations($request): array { $with = $request->input('with'); + if (is_string($with)) { + $with = explode(',', $with); + } + if (!is_array($with)) { return []; } - return array_map(static fn ($relation) => Str::camel($relation), $with); + return array_values(array_filter(array_map('strval', $with), 'strlen')); } /** - * @param string|null $foreignKey the column holding the relation, when the - * relation is a belongsTo; null for the - * inverse side, which has no local column - * @param array $with relations the caller asked for + * The nested object for a relationship. + * + * Internal console requests keep the exact `whenLoaded` behaviour they have + * always had. Public requests get the object only when they asked for it, + * which is also what they got before: these relations were never eager + * loaded on the public endpoints, so the key was absent unless `with` named + * it. Absent stays absent; it never becomes a string. + * + * @param array $with camelCased relations the caller asked for */ - protected function publicRelationField(string $relation, ?string $foreignKey, array $with, \Closure $resource): mixed + protected function publicRelationObject(string $relation, array $with, \Closure $resource): mixed { if (Http::isInternalRequest()) { return $this->whenLoaded($relation, $resource); } - if (in_array($relation, $with, true)) { - if (is_object($this->resource) && method_exists($this->resource, 'loadMissing')) { - $this->loadMissing($relation); - } - - return $this->{$relation} ? $resource() : null; + if (!in_array($relation, $with, true)) { + return new MissingValue(); } - return $this->publicIdForRelation($relation, $foreignKey); + $this->loadRelationIfPossible($relation); + + return $this->{$relation} ? $resource() : null; } /** * The public id behind a relationship, or null when nothing is assigned. + * + * Additive and unconditional: it is present whether or not the object is, + * and it does not change when the object is expanded. + * + * @param string|null $foreignKey the column holding the relation for a belongsTo; + * null for the inverse side, which has no local column */ protected function publicIdForRelation(string $relation, ?string $foreignKey): ?string { @@ -70,14 +89,22 @@ protected function publicIdForRelation(string $relation, ?string $foreignKey): ? return data_get($this, $relation . '.public_id'); } - // Not every resource is wrapped around an Eloquent model — webhook - // payload fixtures and compact serializers pass plain objects — so read - // the already-materialised value rather than calling a relation method - // that is not there. + // Not every resource wraps an Eloquent model — webhook payload fixtures and + // compact serializers pass plain objects — so read the already-materialised + // value rather than calling a relation method that is not there. if (!is_object($resource) || !method_exists($resource, $relation)) { return data_get($this, $relation . '.public_id'); } return $this->{$relation}()->value('public_id'); } + + private function loadRelationIfPossible(string $relation): void + { + $resource = $this->resource; + + if (is_object($resource) && method_exists($resource, 'loadMissing') && method_exists($resource, $relation)) { + $this->loadMissing($relation); + } + } } diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index dec93eb9f..0da9aeea4 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -46,8 +46,8 @@ public function toArray($request) 'vehicle_name' => $this->when(Http::isInternalRequest(), $this->vehicle_name), 'vehicle_avatar' => $this->vehicle_avatar, 'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name), - 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()), - 'current_order_reference' => $this->when(Http::isInternalRequest(), $this->currentOrderReference()), + 'assigned_orders_count' => $this->when(Http::isInternalRequest(), fn () => $this->assignedOrdersCount()), + 'current_order_reference' => $this->when(Http::isInternalRequest(), fn () => $this->currentOrderReference()), 'vehicle' => $this->whenLoaded('vehicle', fn () => new VehicleWithoutDriver($this->vehicle)), 'current_job' => $this->whenLoaded('currentJob', fn () => new Order($this->currentJob)), 'current_job_id' => $this->when(Http::isInternalRequest(), data_get($this, 'currentJob.tracking')), diff --git a/server/src/Http/Resources/v1/Fleet.php b/server/src/Http/Resources/v1/Fleet.php index ee3f0b885..dcf121ab1 100644 --- a/server/src/Http/Resources/v1/Fleet.php +++ b/server/src/Http/Resources/v1/Fleet.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Http\Resources\v1\Concerns\ResolvesPublicRelationFields; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; +use Fleetbase\Support\Resolve; class Fleet extends FleetbaseResource { @@ -19,20 +20,15 @@ class Fleet extends FleetbaseResource */ public function toArray($request) { + // The controller has already mapped these onto real relation names and + // dropped anything outside the public allowlist, so they are safe to + // hand to the loader. `subfleets` used to arrive here spelled exactly + // that and reach `load('subfleets')`, which is not the relation — + // the documented expansion raised instead of resolving. $with = $this->requestedRelations($request); if ($with !== []) { - $this->load($with); - - if (in_array('subfleets', $with, true)) { - if (in_array('drivers', $with, true)) { - $this->loadMissing('subFleets.drivers'); - } - - if (in_array('vehicles', $with, true)) { - $this->loadMissing('subFleets.vehicles'); - } - } + $this->loadMissing($with); } return $this->withCustomFields([ @@ -51,10 +47,20 @@ public function toArray($request) 'drivers_online_count' => $this->when(Http::isInternalRequest(), fn () => $this->drivers_online_count), 'vehicles_count' => $this->when(Http::isInternalRequest(), fn () => $this->vehicles_count), 'vehicles_online_count' => $this->when(Http::isInternalRequest(), fn () => $this->vehicles_online_count), - 'service_area' => $this->publicRelationField('serviceArea', 'service_area_uuid', $with, fn () => new ServiceArea($this->serviceArea)), - 'zone' => $this->publicRelationField('zone', 'zone_uuid', $with, fn () => new Zone($this->zone)), - 'vendor' => $this->publicRelationField('vendor', 'vendor_uuid', $with, fn () => new Vendor($this->vendor)), - 'parent_fleet' => $this->publicRelationField('parentFleet', 'parent_fleet_uuid', $with, fn () => new ParentFleet($this->parentFleet)), + // Additive public identifiers. Always present, unchanged when the + // object beside them is expanded, and never a substitute for it. + 'service_area_id' => $this->publicIdForRelation('serviceArea', 'service_area_uuid'), + 'zone_id' => $this->publicIdForRelation('zone', 'zone_uuid'), + 'vendor_id' => $this->publicIdForRelation('vendor', 'vendor_uuid'), + 'parent_fleet_id' => $this->publicIdForRelation('parentFleet', 'parent_fleet_uuid'), + 'photo_id' => $this->publicIdForRelation('photo', 'image_uuid'), + // The objects keep the shape they have always had: absent unless the + // relation was loaded, an object when it was, never a string. + 'service_area' => $this->publicRelationObject('serviceArea', $with, fn () => new ServiceArea($this->serviceArea)), + 'zone' => $this->publicRelationObject('zone', $with, fn () => new Zone($this->zone)), + 'vendor' => $this->publicRelationObject('vendor', $with, fn () => new Vendor($this->vendor)), + 'parent_fleet' => $this->publicRelationObject('parentFleet', $with, fn () => new ParentFleet($this->parentFleet)), + 'photo' => $this->publicRelationObject('photo', $with, fn () => Resolve::httpResourceForModel($this->photo)), 'subfleets' => $this->whenLoaded('subFleets', fn () => SubFleet::collection($this->subFleets)), 'drivers' => $this->whenLoaded('drivers', fn () => Driver::collection($this->drivers()->with(Http::isInternalRequest() || $request->has('with.jobs') ? ['jobs'] : [])->get())), 'vehicles' => $this->whenLoaded('vehicles', fn () => Vehicle::collection($this->vehicles)), diff --git a/server/src/Http/Resources/v1/Vehicle.php b/server/src/Http/Resources/v1/Vehicle.php index f37f38c5a..ed1b73354 100644 --- a/server/src/Http/Resources/v1/Vehicle.php +++ b/server/src/Http/Resources/v1/Vehicle.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; +use Fleetbase\Support\Resolve; class Vehicle extends FleetbaseResource { @@ -22,6 +23,10 @@ public function toArray($request) { $with = $this->requestedRelations($request); + if ($with !== []) { + $this->loadMissing($with); + } + return $this->withCustomFields([ // Identity 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), @@ -44,14 +49,24 @@ public function toArray($request) 'description' => $this->description, 'driver_name' => $this->when(Http::isInternalRequest(), $this->driver_name), 'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name), - 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()), - 'current_order_reference'=> $this->when(Http::isInternalRequest(), $this->currentOrderReference()), - // Relationships. A public caller receives the assignment as a public id - // so a write can be read back; `?with=driver` still returns the object. - 'driver' => $this->publicRelationField('driver', null, $with, fn () => new Driver($this->driver)), - 'vendor' => $this->publicRelationField('vendor', 'vendor_uuid', $with, fn () => new Vendor($this->vendor)), - 'category' => $this->when(Http::isPublicRequest(), fn () => $this->publicIdForRelation('category', 'category_uuid')), - 'warranty' => $this->when(Http::isPublicRequest(), fn () => $this->publicIdForRelation('warranty', 'warranty_uuid')), + 'assigned_orders_count' => $this->when(Http::isInternalRequest(), fn () => $this->assignedOrdersCount()), + 'current_order_reference'=> $this->when(Http::isInternalRequest(), fn () => $this->currentOrderReference()), + // Relationships. The identifier is additive and always present so a + // write can be read back; the object beside it keeps the shape it + // has always had — absent unless loaded, an object when it is. + 'driver_id' => $this->publicIdForRelation('driver', null), + 'vendor_id' => $this->publicIdForRelation('vendor', 'vendor_uuid'), + 'category_id' => $this->publicIdForRelation('category', 'category_uuid'), + 'warranty_id' => $this->publicIdForRelation('warranty', 'warranty_uuid'), + 'photo_id' => $this->publicIdForRelation('photo', 'photo_uuid'), + 'driver' => $this->publicRelationObject('driver', $with, fn () => new Driver($this->driver)), + 'vendor' => $this->publicRelationObject('vendor', $with, fn () => new Vendor($this->vendor)), + // Resolved through the repository's own resource resolver rather than + // a hardcoded class: Category and File live in core, and Warranty has + // no v1 resource of its own. + 'category' => $this->publicRelationObject('category', $with, fn () => Resolve::httpResourceForModel($this->category)), + 'warranty' => $this->publicRelationObject('warranty', $with, fn () => Resolve::httpResourceForModel($this->warranty)), + 'photo' => $this->publicRelationObject('photo', $with, fn () => Resolve::httpResourceForModel($this->photo)), 'devices' => $this->whenLoaded('devices', fn () => $this->devices), // Vehicle identification 'make' => $this->make, diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index 4e49ab04f..fa5e4dfa7 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -836,3 +836,72 @@ public function or(array $keys, mixed $default = null): mixed expect($controller->relationCompanyScopes)->toBe(['company-uuid', 'company-uuid']); }); + +test('api driver controller keeps every relationship the base contract returned', function () { + // The SDK stores what the API returns verbatim, and Navigator interpolates + // `driver.user` straight into a socket channel name — an object there would + // subscribe it to `user.[object Object]` and quietly stop delivering + // messages. These endpoints have always loaded user, vehicle, vendor and + // currentJob, so they must keep doing it without a `with` parameter. + $controller = new FleetOpsApiDriverControllerProbe(); + + $controller->create(new CreateDriverRequest([ + 'name' => 'Driver One', + 'vehicle' => 'vehicle_public', + 'vendor' => 'vendor_public', + 'job' => 'order_public', + ])); + + expect($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']); + + $updateController = new FleetOpsApiDriverControllerProbe(); + $updateController->update('driver_public', new UpdateDriverRequest(['name' => 'Renamed'])); + + expect($updateController->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']) + ->and($updateController->findCalls)->toContain(['driver_public', ['user']]); +}); + +test('api driver controller copies timezone through to the linked user account', function () { + // `timezone` is documented and accepted on both create and update, but the + // update only ever copied name, email and phone — so the request validated, + // answered 200, and dropped it. The driver has no timezone column; the + // linked user does. + $create = new FleetOpsApiDriverControllerProbe(); + $create->create(new CreateDriverRequest([ + 'name' => 'Driver One', + 'timezone' => 'Asia/Singapore', + ])); + + $user = new FleetOpsApiDriverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid'], true); + + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); + $driver->userForTest = $user; + $driver->setRelation('user', $user); + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + $update->update('driver_public', new UpdateDriverRequest([ + 'name' => 'Driver One', + 'timezone' => 'Europe/Amsterdam', + ])); + + expect($create->createdUsers[0])->toMatchArray(['timezone' => 'Asia/Singapore']) + ->and($user->updates)->toContain([ + 'name' => 'Driver One', + 'timezone' => 'Europe/Amsterdam', + ]); +}); + +test('api driver controller only expands relationships the public contract allows', function () { + $controller = new FleetOpsApiDriverControllerProbe(); + $request = new CreateDriverRequest(['name' => 'Driver One', 'with' => ['vehicle', 'current_job', 'user', 'company', 'nope']]); + + $controller->create($request); + + // `user` and `company` are published as public-id strings. Expanding either + // would retype a released field, so neither is expandable at all; an unknown + // name is dropped rather than reaching Eloquent, where it would be a 500. + expect($request->input('with'))->toBe(['vehicle', 'currentJob']); +}); diff --git a/server/tests/ControllerFilterContractsTest.php b/server/tests/ControllerFilterContractsTest.php index 9f34d61b4..976cb5652 100644 --- a/server/tests/ControllerFilterContractsTest.php +++ b/server/tests/ControllerFilterContractsTest.php @@ -572,8 +572,11 @@ public function get(string $key): ?string ->and($query->calls)->toContain(['search', 'van']) ->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter']) ->and($query->calls)->toContain(['searchWhere', 'vin', 'vin-123']) - ->and($query->calls)->toContain(['searchWhere', 'public_id', 'vehicle-public']) - ->and($query->calls)->toContain(['searchWhere', 'internal_id', 'VEH-42']) + // A public id names exactly one record, so a public lookup is exact. + ->and($query->calls)->toContain(['where', ['public_id', '=', 'vehicle-public']]) + // Exact, not partial: an importer asking whether VEH-42 exists must not + // be told yes because VEH-420 does. + ->and($query->calls)->toContain(['where', ['internal_id', '=', 'VEH-42']]) ->and($query->calls)->toContain(['searchWhere', 'plate_number', 'ABC-123']) ->and($query->calls)->toContain(['searchWhere', 'make', 'Mercedes']) ->and($query->calls)->toContain(['searchWhere', 'model', 'Sprinter']) @@ -621,7 +624,7 @@ public function get(string $key): ?string expect($query->calls)->toContain(['where', ['company_uuid', 'company-uuid']]) ->and($query->calls)->toContain(['with', ['serviceArea', 'zone']]) ->and($query->calls)->toContain(['whereNull', 'parent_fleet_uuid']) - ->and($query->calls)->toContain(['searchWhere', 'public_id', 'fleet-public']) + ->and($query->calls)->toContain(['where', ['public_id', '=', 'fleet-public']]) ->and($query->calls)->toContain(['searchWhere', 'task', 'delivery']) ->and($query->calls)->toContain(['searchWhere', 'name', 'North Fleet']) ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']]) diff --git a/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php b/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php new file mode 100644 index 000000000..265380048 --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php @@ -0,0 +1,140 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function getDatabaseName(): string + { + return 'main'; + } + + public function table($table, $as = null) + { + return $this->c->table($table, $as); + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + Schema::clearResolvedInstance('db.schema'); + + $schema = $connection->getSchemaBuilder(); + foreach (['fleet_vehicles' => 'vehicle_uuid', 'fleet_drivers' => 'driver_uuid'] as $table => $subjectColumn) { + $schema->create($table, function ($blueprint) use ($subjectColumn) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('fleet_uuid')->nullable(); + $blueprint->string($subjectColumn)->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + return $connection; +} + +/** + * Reach the migration's cleanup directly. `information_schema` does not exist on + * sqlite, so the index step is exercised separately in FleetMembershipTest. + */ +function fleetopsRunMembershipCleanup(string $table, string $memberColumn): void +{ + $migration = require dirname(__DIR__, 4) . '/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php'; + $reflection = new ReflectionMethod($migration, 'removeDuplicateMemberships'); + $reflection->setAccessible(true); + $reflection->invoke($migration, $table, $memberColumn); +} + +test('duplicate cleanup keeps the active membership and drops the rest', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_vehicles')->insert([ + // A tombstone recorded first, then the membership the fleet actually has. + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => '2026-01-01 00:00:00'], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + // A different pair, and an unrelated fleet, both untouched. + ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-2', 'deleted_at' => null], + ['id' => 5, 'uuid' => 'e', 'fleet_uuid' => 'fleet-2', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ]); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + + $survivors = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + // The live row wins over the tombstone, the lowest id wins among equals, and + // nothing outside the duplicated pair is affected. + expect($survivors)->toBe(['b', 'd', 'e']) + ->and($connection->table('fleet_vehicles')->where('uuid', 'b')->value('deleted_at'))->toBeNull(); +}); + +test('duplicate cleanup keeps one restorable row when every duplicate is soft deleted', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_drivers')->insert([ + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-01-01 00:00:00'], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-02-01 00:00:00'], + ]); + + fleetopsRunMembershipCleanup('fleet_drivers', 'driver_uuid'); + + // Removing both would turn a later re-assignment into a brand new row and + // lose the original membership's history. + expect($connection->table('fleet_drivers')->pluck('uuid')->all())->toBe(['a']) + ->and($connection->table('fleet_drivers')->where('uuid', 'a')->value('deleted_at'))->not->toBeNull(); +}); + +test('duplicate cleanup is idempotent and leaves orphaned rows alone', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_vehicles')->insert([ + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + // Orphaned data rather than a membership: a null side never collides in + // a unique index, so it is not the migration's business. + ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null], + ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null], + ]); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + $afterFirst = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + $afterSecond = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + expect($afterFirst)->toBe(['a', 'c', 'd']) + ->and($afterSecond)->toBe($afterFirst); +}); diff --git a/server/tests/Feature/Http/Api/FleetMembershipTest.php b/server/tests/Feature/Http/Api/FleetMembershipTest.php index 50bc5b213..6af03c764 100644 --- a/server/tests/Feature/Http/Api/FleetMembershipTest.php +++ b/server/tests/Feature/Http/Api/FleetMembershipTest.php @@ -24,10 +24,19 @@ function fleetopsMembershipDatabase(): SQLiteConnection $resolver->setDefaultConnection('mysql'); EloquentModel::setConnectionResolver($resolver); - // A model dispatcher brings the lifecycle observers with it — the HTTP cache + // A dispatcher is required, not optional: without one the uuid hooks never + // fire and — more importantly here — `FleetVehicle::creating()` silently + // registers nothing, so a race test would pass without ever racing. + // Memoised, because a fresh dispatcher drops the hooks of models already + // booted in this process. + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + // The dispatcher brings the lifecycle observers with it — the HTTP cache // observer calls the `responsecache` binding on every create and delete — // so satisfy it rather than letting an unrelated facade fail the assertion. - if (EloquentModel::getEventDispatcher() && !app()->bound('responsecache')) { + if (!app()->bound('responsecache')) { app()->instance('responsecache', new class { public function clear(): void { @@ -36,9 +45,17 @@ public function clear(): void public function forget($uris): void { } + + public function __call($method, $arguments) + { + return null; + } }); } + config()->set('activitylog.enabled', false); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + app()->instance('db', new class($connection) { public function __construct(public SQLiteConnection $c) { @@ -59,7 +76,7 @@ public function __call($method, $arguments) $schema = $connection->getSchemaBuilder(); foreach (['fleet_vehicles' => 'vehicle_uuid', 'fleet_drivers' => 'driver_uuid'] as $table => $subjectColumn) { - $schema->create($table, function ($blueprint) use ($subjectColumn) { + $schema->create($table, function ($blueprint) use ($table, $subjectColumn) { $blueprint->increments('id'); $blueprint->string('uuid')->nullable(); $blueprint->string('_key')->nullable(); @@ -67,6 +84,11 @@ public function __call($method, $arguments) $blueprint->string($subjectColumn)->nullable(); $blueprint->timestamps(); $blueprint->timestamp('deleted_at')->nullable(); + + // The same composite index the migration adds. Soft-deleted rows are + // deliberately inside the key: a removed membership is restored on + // re-assignment rather than replaced, so its key must stay taken. + $blueprint->unique(['fleet_uuid', $subjectColumn], $table . '_pair_unique'); }); } @@ -208,3 +230,104 @@ function fleetopsMembershipDriver(string $uuid, string $publicId, ?string $vehic expect(FleetVehicle::where('vehicle_uuid', 'vehicle-uuid-1')->pluck('fleet_uuid')->all())->toBe(['fleet-uuid-1']); }); + +test('the composite index refuses a duplicate membership pair outright', function () { + $connection = fleetopsMembershipDatabase(); + + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-1', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]); + + // The controller's read-then-write is idempotent only against itself. This + // is the guarantee underneath it: even a direct insert cannot produce a + // second logical membership. + expect(fn () => $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-2', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); + + // A tombstone still occupies the key, which is what forces the restore path. + $connection->table('fleet_vehicles')->where('uuid', 'pivot-1')->update(['deleted_at' => now()]); + + expect(fn () => $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-3', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); +}); + +test('a competing request that wins the race is adopted rather than reported as an error', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + // The row appears between this request reading an empty table and writing to + // it — the interleaving a retrying importer actually produces. Inserted + // through the query builder so no model event recurses. + $inserted = false; + FleetVehicle::creating(function () use ($connection, &$inserted) { + if ($inserted) { + return; + } + + $inserted = true; + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'winner', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + 'deleted_at' => now(), + ]); + }); + + try { + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + } finally { + FleetVehicle::flushEventListeners(); + } + + // The loser adopts the winner's row and restores it, so both callers get the + // same successful answer and there is still exactly one membership. + expect($connection->table('fleet_vehicles')->count())->toBe(1) + ->and(FleetVehicle::where([ + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ])->count())->toBe(1); +}); + +test('a duplicate key that is not this membership is not swallowed', function () { + $connection = fleetopsMembershipDatabase(); + $connection->statement('create unique index fleet_vehicles_uuid_unique on fleet_vehicles (uuid)'); + + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + // A violation on a different key means something else is wrong. Reporting it + // as a successful assignment would hide a real failure behind a 200. + $collided = false; + FleetVehicle::creating(function ($membership) use ($connection, &$collided) { + if ($collided) { + return; + } + + $collided = true; + + // Pin the uuid and take it first, so the save collides on the uuid index + // rather than on the membership pair. + $membership->uuid = 'collides-on-uuid'; + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'collides-on-uuid', + 'fleet_uuid' => 'fleet-uuid-9', + 'vehicle_uuid' => 'vehicle-uuid-9', + ]); + }); + + try { + expect(fn () => fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle)) + ->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); + } finally { + FleetVehicle::flushEventListeners(); + } +}); diff --git a/server/tests/Feature/Http/Api/FleetPublicContractTest.php b/server/tests/Feature/Http/Api/FleetPublicContractTest.php index 1fea58ea9..24730da91 100644 --- a/server/tests/Feature/Http/Api/FleetPublicContractTest.php +++ b/server/tests/Feature/Http/Api/FleetPublicContractTest.php @@ -214,47 +214,71 @@ function fleetopsFleetResourceFixture(array $overrides = []): Fleet return Fleet::where('uuid', 'fleet-uuid')->firstOrFail(); } -test('the public fleet resource reports every writable field and each relationship as a public id', function () { - $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve(fleetopsFleetResourceRequest(false)); +/** + * Resolve expansions the way a request does, then serialize. + * + * Going through the controller matters: it is what maps the public expansion + * name onto the relation name and drops anything outside the allowlist, and the + * resource is written to trust that mapping. + */ +function fleetopsFleetPayload(Fleet $fleet, bool $internal = false, array $query = []): array +{ + $request = fleetopsFleetResourceRequest($internal, $query); + + $controller = new FleetController(); + $reflection = new ReflectionMethod(FleetController::class, 'applyPublicExpansions'); + $reflection->setAccessible(true); + $reflection->invoke($controller, $request, FleetController::EXPANDABLE); - // A caller that just wrote `parent_fleet: "fleet_parent123"` has to be able - // to read the assignment back, and the public contract never exposes the - // uuid the column actually holds. + return (new FleetResource($fleet))->resolve($request); +} + +test('the public fleet resource reports every writable field and each relationship as an additive id', function () { + $payload = fleetopsFleetPayload(fleetopsFleetResourceFixture()); + + // The identifier is a new key beside the relationship, never a value under + // it. The SDK stores whatever the API returns verbatim, so a property that + // is an object on one call and a string on another breaks every consumer + // that dereferences it. expect($payload)->toMatchArray([ - 'id' => 'fleet_123', - 'name' => 'Carpool', - 'color' => '#2563EB', - 'task' => 'Employee transport', - 'status' => 'active', - 'service_area' => 'service_area_123', - 'zone' => 'zone_123', - 'vendor' => 'vendor_123', - 'parent_fleet' => 'fleet_parent123', - ])->and($payload)->not->toHaveKeys([ - 'uuid', - 'public_id', - 'company_uuid', - 'service_area_uuid', - 'zone_uuid', - 'vendor_uuid', - 'parent_fleet_uuid', - 'image_uuid', - ]); + 'id' => 'fleet_123', + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'service_area_id' => 'service_area_123', + 'zone_id' => 'zone_123', + 'vendor_id' => 'vendor_123', + 'parent_fleet_id' => 'fleet_parent123', + ]) + // Unexpanded, the object keys stay absent exactly as they were before + // this contract gained the identifiers. + ->and($payload)->not->toHaveKeys(['service_area', 'zone', 'vendor', 'parent_fleet', 'photo']) + ->and($payload)->not->toHaveKeys([ + 'uuid', + 'public_id', + 'company_uuid', + 'service_area_uuid', + 'zone_uuid', + 'vendor_uuid', + 'parent_fleet_uuid', + 'image_uuid', + ]); }); -test('the public fleet resource reports a root fleet with a null parent', function () { - $fleet = fleetopsFleetResourceFixture(['parent_fleet_uuid' => null]); +test('the public fleet resource reports a root fleet with null relationship ids', function () { + $fleet = fleetopsFleetResourceFixture(['parent_fleet_uuid' => null, 'zone_uuid' => null]); - $payload = (new FleetResource($fleet))->resolve(fleetopsFleetResourceRequest(false)); + $payload = fleetopsFleetPayload($fleet); - expect($payload)->toHaveKey('parent_fleet') - ->and($payload['parent_fleet'])->toBeNull(); + expect($payload)->toHaveKeys(['parent_fleet_id', 'zone_id']) + ->and($payload['parent_fleet_id'])->toBeNull() + ->and($payload['zone_id'])->toBeNull(); }); test('the internal fleet resource keeps its nested relationship shape', function () { - $fleet = fleetopsFleetResourceFixture()->load(['serviceArea', 'zone', 'vendor', 'parentFleet']); - - $payload = (new FleetResource($fleet))->resolve(fleetopsFleetResourceRequest(true)); + $fleet = fleetopsFleetResourceFixture()->load(['serviceArea', 'zone', 'vendor', 'parentFleet']); + $payload = fleetopsFleetPayload($fleet, true); // The console reads nested objects off these keys, and reads the counters; // expanding the public contract must not reshape either. @@ -266,13 +290,56 @@ function fleetopsFleetResourceFixture(array $overrides = []): Fleet ->and($payload['parent_fleet'])->toBeObject(); }); -test('the public fleet resource still nests a relationship the caller asked for', function () { - $request = fleetopsFleetResourceRequest(false, ['with' => ['service_area']]); - $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve($request); +test('expanding a fleet relationship adds the object and leaves the id untouched', function () { + $payload = fleetopsFleetPayload( + fleetopsFleetResourceFixture(), + false, + ['with' => ['service_area', 'parent_fleet']] + ); expect($payload['service_area'])->toBeObject() - // Relations that were not requested stay as public ids. - ->and($payload['vendor'])->toBe('vendor_123'); + ->and($payload['parent_fleet'])->toBeObject() + // The identifier is unchanged by expansion, and agrees with the object. + ->and($payload['service_area_id'])->toBe('service_area_123') + ->and($payload['parent_fleet_id'])->toBe('fleet_parent123') + // The expanded object and the identifier agree. Asserted on the parent + // fleet: a ServiceArea serializes its border through GEOS, which the + // harness has no extension for. + ->and($payload['parent_fleet']->resolve()['id'])->toBe($payload['parent_fleet_id']) + // A relation that was not asked for is still absent, not a string. + ->and($payload)->not->toHaveKey('vendor') + ->and($payload['vendor_id'])->toBe('vendor_123'); +}); + +test('fleet expansion accepts every documented input spelling', function () { + $scalar = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area']); + $array = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => ['service_area']]); + $csv = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area,vendor']); + $expand = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['expand' => ['service_area']]); + $camel = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'serviceArea']); + + // ?with=x, ?with[]=x, ?with=x,y and ?expand=x all mean the same thing. + expect($scalar['service_area'])->toBeObject() + ->and($array['service_area'])->toBeObject() + ->and($csv['service_area'])->toBeObject() + ->and($csv['vendor'])->toBeObject() + ->and($expand['service_area'])->toBeObject() + ->and($camel['service_area'])->toBeObject(); +}); + +test('an unsupported fleet expansion is ignored rather than reaching eloquent', function () { + // Core hands `with` straight to load(), so an unknown name would be a 500 + // for what is only a typo. The allowlist drops it and the response is + // otherwise unchanged. + $payload = fleetopsFleetPayload( + fleetopsFleetResourceFixture(), + false, + ['with' => ['not_a_relation', 'company', 'service_area']] + ); + + expect($payload['service_area'])->toBeObject() + ->and($payload)->not->toHaveKeys(['not_a_relation', 'company']) + ->and($payload['id'])->toBe('fleet_123'); }); test('public fleet membership routes are registered with public id parameters', function () { @@ -358,3 +425,15 @@ function fleetopsFleetResourceFixture(array $overrides = []): Fleet ->and($results->first()->relationLoaded('serviceArea'))->toBeTrue() ->and($results->first()->relationLoaded('parentFleet'))->toBeTrue(); }); + +test('the fleet resource accepts a scalar with parameter it was handed directly', function () { + // The controller normalises `with` into an array before the resource runs, + // but the resource is also reachable from code that passes the raw scalar + // through — it must read the same list either way. + $request = fleetopsFleetResourceRequest(false, ['with' => 'serviceArea,parentFleet']); + $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve($request); + + expect($payload['service_area'])->toBeObject() + ->and($payload['parent_fleet'])->toBeObject() + ->and($payload['service_area_id'])->toBe('service_area_123'); +}); diff --git a/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php b/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php new file mode 100644 index 000000000..6bb3248e3 --- /dev/null +++ b/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php @@ -0,0 +1,293 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + // `searchWhere` is a Builder macro registered by core's service provider, + // which does not boot here. Mirrors the real one: case-insensitive LIKE with + // dots and commas treated as wildcards. + if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('searchWhere')) { + Illuminate\Database\Eloquent\Builder::macro('searchWhere', function ($column, $search, $strict = false) { + if ($strict === true) { + return $this->where($column, $search); + } + + $needle = '%' . str_replace(['.', ','], '%', (string) $search) . '%'; + + return $this->where(DB::raw('lower(' . $column . ')'), 'like', strtolower($needle)); + }); + } + + $schema = $connection->getSchemaBuilder(); + foreach (['vehicles', 'drivers', 'users'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $rows = ['VEH-10', 'VEH-100', 'VEH-101']; + $index = 0; + foreach ($rows as $internalId) { + $index++; + $connection->table('vehicles')->insert([ + 'uuid' => 'vehicle-uuid-' . $index, + 'public_id' => 'vehicle_id' . $index, + 'internal_id' => $internalId, + 'company_uuid' => 'company-uuid', + ]); + $connection->table('users')->insert(['uuid' => 'user-uuid-' . $index, 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert([ + 'uuid' => 'driver-uuid-' . $index, + 'public_id' => 'driver_id' . $index, + 'internal_id' => str_replace('VEH', 'DRV', $internalId), + 'company_uuid' => 'company-uuid', + 'user_uuid' => 'user-uuid-' . $index, + ]); + } + + // Another tenant holding the very same internal id. + $connection->table('vehicles')->insert([ + 'uuid' => 'vehicle-other-company', + 'public_id' => 'vehicle_other01', + 'internal_id' => 'VEH-10', + 'company_uuid' => 'another-company', + ]); + $connection->table('users')->insert(['uuid' => 'user-other', 'company_uuid' => 'another-company']); + $connection->table('drivers')->insert([ + 'uuid' => 'driver-other-company', + 'public_id' => 'driver_other01', + 'internal_id' => 'DRV-10', + 'company_uuid' => 'another-company', + 'user_uuid' => 'user-other', + ]); + + session(['company' => 'company-uuid']); + + return $connection; +} + +class FleetOpsIdentifierFilterRoute +{ + public array $action = []; + + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } +} + +function fleetopsIdentifierFilterRequest(string $uri, array $query): Request +{ + $request = Request::create('/' . $uri, 'GET', $query); + $session = app('session.store'); + $session->put('company', 'company-uuid'); + $request->setLaravelSession($session); + $request->setRouteResolver(fn () => new FleetOpsIdentifierFilterRoute($uri)); + + return $request; +} + +/** + * @return array + */ +function fleetopsFilteredInternalIds(string $filterClass, string $modelClass, string $uri, array $query): array +{ + $filter = new $filterClass(fleetopsIdentifierFilterRequest($uri, $query)); + + return $filter->apply($modelClass::query())->pluck('internal_id')->sort()->values()->all(); +} + +test('a public vehicle lookup by internal id matches exactly and stays inside the tenant', function () { + fleetopsIdentifierFilterBoot(); + + $matched = fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['internal_id' => 'VEH-10']); + + // VEH-100 and VEH-101 are different vehicles, and the other tenant's VEH-10 + // is a different vehicle again. + expect($matched)->toBe(['VEH-10']); +}); + +test('the console keeps partial internal id search on vehicles', function () { + fleetopsIdentifierFilterBoot(); + + $matched = fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'int/v1/fleet-ops/vehicles', ['internal_id' => 'VEH-10']); + + expect($matched)->toBe(['VEH-10', 'VEH-100', 'VEH-101']); +}); + +test('a public driver lookup by internal id matches exactly and stays inside the tenant', function () { + fleetopsIdentifierFilterBoot(); + + expect(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'v1/drivers', ['internal_id' => 'DRV-10'])) + ->toBe(['DRV-10']); +}); + +test('the console keeps partial internal id search on drivers', function () { + fleetopsIdentifierFilterBoot(); + + expect(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'int/v1/fleet-ops/drivers', ['internal_id' => 'DRV-10'])) + ->toBe(['DRV-10', 'DRV-100', 'DRV-101']); +}); + +test('a public lookup by public id is exact for vehicles and drivers', function () { + fleetopsIdentifierFilterBoot(); + + // vehicle_id1 is a prefix of nothing here, so the partial and exact forms + // agree — the assertion that matters is that a public request produces an + // equality comparison at all, which the tenant row below proves. + expect(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['public_id' => 'vehicle_id1'])) + ->toBe(['VEH-10']) + ->and(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'v1/drivers', ['public_id' => 'driver_id1'])) + ->toBe(['DRV-10']) + // Another tenant's record is never reachable, by either identifier. + ->and(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['public_id' => 'vehicle_other01'])) + ->toBe([]); +}); + +test('vin and plate number keep the search behaviour they already had', function () { + $connection = fleetopsIdentifierFilterBoot(); + $connection->getSchemaBuilder()->table('vehicles', function ($blueprint) { + $blueprint->string('vin')->nullable(); + $blueprint->string('plate_number')->nullable(); + }); + $connection->table('vehicles')->where('uuid', 'vehicle-uuid-1')->update(['vin' => 'VIN1234567890', 'plate_number' => 'SG-1000']); + $connection->table('vehicles')->where('uuid', 'vehicle-uuid-2')->update(['vin' => 'VIN1234567891', 'plate_number' => 'SG-10001']); + + // Untouched by this change: both still match on a prefix. + expect(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['vin' => 'VIN123456789'])) + ->toBe(['VEH-10', 'VEH-100']) + ->and(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['plate_number' => 'SG-1000'])) + ->toBe(['VEH-10', 'VEH-100']); +}); + +test('the console keeps partial public id search on vehicles drivers and fleets', function () { + $connection = fleetopsIdentifierFilterBoot(); + $connection->getSchemaBuilder()->create('fleets', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('fleets')->insert([ + ['uuid' => 'fleet-1', 'public_id' => 'fleet_id1', 'company_uuid' => 'company-uuid', 'name' => 'One'], + ['uuid' => 'fleet-2', 'public_id' => 'fleet_id12', 'company_uuid' => 'company-uuid', 'name' => 'Two'], + ]); + + // The console's id column filter is a search box; the public API's is a + // lookup. Both behaviours are kept, chosen by the resolved route. + $vehicles = (new VehicleFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/vehicles', ['public_id' => 'vehicle_id1']))) + ->apply(Vehicle::query())->pluck('public_id')->sort()->values()->all(); + + $drivers = (new DriverFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/drivers', ['public_id' => 'driver_id1']))) + ->apply(Driver::query())->pluck('public_id')->sort()->values()->all(); + + $fleets = (new Fleetbase\FleetOps\Http\Filter\FleetFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/fleets', ['public_id' => 'fleet_id1']))) + ->apply(Fleetbase\FleetOps\Models\Fleet::query())->pluck('public_id')->sort()->values()->all(); + + expect($vehicles)->toBe(['vehicle_id1']) + ->and($drivers)->toBe(['driver_id1']) + // fleet_id1 is a prefix of fleet_id12, so the console returns both. + ->and($fleets)->toBe(['fleet_id1', 'fleet_id12']); + + // The public API returns only the exact match. + $publicFleets = (new Fleetbase\FleetOps\Http\Filter\FleetFilter(fleetopsIdentifierFilterRequest('v1/fleets', ['public_id' => 'fleet_id1']))) + ->apply(Fleetbase\FleetOps\Models\Fleet::query())->pluck('public_id')->all(); + + expect($publicFleets)->toBe(['fleet_id1']); +}); + +test('the driver update request scopes its uniqueness lookup to the caller company', function () { + fleetopsIdentifierFilterBoot(); + + $request = Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest::create('/v1/drivers/driver_id1', 'PUT', ['email' => 'a@example.test']); + $request->setRouteResolver(fn () => new class { + public function parameter($name, $default = null) + { + return $name === 'id' ? 'driver_id1' : $default; + } + + public function uri(): string + { + return 'v1/drivers/{id}'; + } + }); + app()->instance('request', $request); + + $reflection = new ReflectionMethod($request, 'linkedUserUuid'); + $reflection->setAccessible(true); + + // Ignoring the driver's own user by uuid is what lets an unchanged address + // be resent without tripping the uniqueness rule. + expect($reflection->invoke($request))->toBe('user-uuid-1'); + + // Another tenant's driver is not reachable, so its user is never ignored. + $foreign = Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest::create('/v1/drivers/driver_other01', 'PUT', []); + $foreign->setRouteResolver(fn () => new class { + public function parameter($name, $default = null) + { + return $name === 'id' ? 'driver_other01' : $default; + } + + public function uri(): string + { + return 'v1/drivers/{id}'; + } + }); + + expect($reflection->invoke($foreign))->toBeNull(); +}); diff --git a/server/tests/Feature/Http/Api/VehiclePublicContractTest.php b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php new file mode 100644 index 000000000..52be7c4ba --- /dev/null +++ b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php @@ -0,0 +1,434 @@ +only()` does not catch it. + */ +if (!function_exists('Fleetbase\Observers\event')) { + eval('namespace Fleetbase\Observers; function event($event = null) { return $event; }'); +} + +if (!function_exists('Fleetbase\Support\auth')) { + eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }'); +} + +if (!function_exists('Fleetbase\Support\session')) { + 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); }'); +} + +if (!Request::hasMacro('getController')) { + Request::macro('getController', fn () => new VehicleController()); +} + +if (!Request::hasMacro('or')) { + Request::macro('or', function (array $params = [], $default = null) { + foreach ($params as $param) { + if ($this->has($param)) { + return $this->input($param); + } + } + + return $default; + }); +} + +class FleetOpsVehicleContractRoute +{ + public array $action = []; + + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } + + public function getAction($key = null): string + { + return VehicleController::class . '@query'; + } + + public function getActionMethod(): string + { + return 'query'; + } + + public function getName(): string + { + return 'api.v1.vehicles.query'; + } + + public function parameters(): array + { + return []; + } +} + +function fleetopsVehicleContractBoot(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + + // The spatial grammar wraps a point in ST_GeomFromText even on sqlite, so the + // function has to exist for a vehicle carrying coordinates to save at all. + foreach (['ST_PointFromText', 'ST_GeomFromText'] as $fn) { + $pdo->sqliteCreateFunction($fn, fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + } + + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + // Without a dispatcher the uuid and public_id creating hooks never fire and + // every record comes back with a null id — which reads as a serialization + // bug rather than a missing fixture. Memoised: a fresh dispatcher would drop + // the hooks of models booted earlier in the process. + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + if (!app()->bound('responsecache')) { + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + } + + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); + } + + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + $vehicle = new Vehicle(); + + $schema->create('vehicles', function ($blueprint) use ($vehicle) { + $blueprint->increments('id'); + foreach (array_unique(array_merge($vehicle->getFillable(), ['uuid', 'public_id', '_key', 'telematic_uuid'])) as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + foreach (['vendors', 'categories', 'warranties', 'files', 'drivers', 'users', 'directives', 'orders', 'custom_field_values', 'custom_fields', 'places', 'contacts', 'vendor_personnels', 'integrated_vendors'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach ([ + 'uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', + 'name', '_key', 'for', 'owner_uuid', 'owner_type', 'subject_uuid', 'subject_type', + 'permission_uuid', 'key', 'rules', 'disk', 'path', 'bucket', 'type', 'original_filename', + 'provider', 'policy_number', 'status', 'vehicle_assigned_uuid', 'driver_assigned_uuid', 'tracking', + 'custom_field_uuid', 'value', 'value_type', 'label', 'contact_uuid', 'vendor_uuid', 'place_uuid', + ] as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-uuid']); + $connection->table('vendors')->insert(['uuid' => 'vendor-uuid', 'public_id' => 'vendor_contract1', 'company_uuid' => 'company-uuid', 'name' => 'Acme']); + $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_contract1', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']); + + return $connection; +} + +function fleetopsVehicleContractRequest(string $class, string $method, array $payload, string $uri = 'v1/vehicles') +{ + $request = $class::create('/' . $uri, $method, $payload); + $store = app('session.store'); + $store->put('company', 'company-uuid'); + $request->setLaravelSession($store); + $request->setRouteResolver(fn () => new FleetOpsVehicleContractRoute($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * Every safe writable field, with a value whose type exercises its cast. + * + * @return array + */ +function fleetopsVehicleContractPayload(): array +{ + return [ + 'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van', + 'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024, + 'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1', + 'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001', + 'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001', + 'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12, + 'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l', + 'online' => true, 'status' => 'available', + 'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb', + 'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5, + 'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06, + 'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400, + 'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70, + 'financing_status' => 'financed', 'loan_number_of_payments' => 48, + 'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD', + 'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000, + 'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5, + 'current_value' => 38000, 'acquisition_cost' => 52000, + 'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02', + 'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false, + 'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue', + 'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline', + 'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168, + 'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750, + 'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4', + 'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool', + 'meta' => ['depot' => 'north'], + 'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25, + 'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40, + 'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true, + 'altitude' => 15, 'heading' => 180, 'speed' => 42, + ]; +} + +/** + * Compare a serialized response against what was sent, allowing for the casts + * the model applies on the way through. + * + * @return array fields whose round trip did not hold + */ +function fleetopsVehicleContractMismatches(array $sent, array $payload): array +{ + $mismatches = []; + + foreach ($sent as $field => $expected) { + if (!array_key_exists($field, $payload)) { + $mismatches[] = $field . ' (absent from the response)'; + continue; + } + + $actual = $payload[$field]; + + if (is_array($expected)) { + if (json_encode($actual) !== json_encode($expected)) { + $mismatches[] = $field; + } + continue; + } + + if (is_bool($expected)) { + if ((bool) $actual !== $expected) { + $mismatches[] = $field; + } + continue; + } + + if (is_numeric($expected) && is_numeric($actual)) { + // Decimal casts return strings; the value is what matters, not the shape. + if (abs(((float) $actual) - ((float) $expected)) > 0.0001) { + $mismatches[] = $field; + } + continue; + } + + // Dates and datetimes are the documented canonical transformation: a + // `2026-01-02` goes in and an ISO-8601 instant comes back out. + if ($actual instanceof DateTimeInterface) { + if ($actual->format('Y-m-d') !== substr((string) $expected, 0, 10)) { + $mismatches[] = $field . ' (' . $actual->format('c') . ' != ' . $expected . ')'; + } + continue; + } + + $actualString = is_scalar($actual) ? (string) $actual : trim((string) json_encode($actual), '"'); + + // Times come back with seconds appended. + if ($actualString !== '' && str_starts_with($actualString, (string) $expected)) { + continue; + } + + if ($actualString !== (string) $expected) { + $mismatches[] = $field . ' (' . $actualString . ' != ' . $expected . ')'; + } + } + + return $mismatches; +} + +test('every safe writable vehicle field survives create retrieve update and query', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $sent = fleetopsVehicleContractPayload(); + + // ---- create ---- + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', $sent + ['vendor' => 'vendor_contract1']); + $created = $controller->create($createRequest)->resolve($createRequest); + + expect(fleetopsVehicleContractMismatches($sent, $created))->toBe([]) + ->and($created['id'])->toMatch('/^vehicle_/') + ->and($created['vendor_id'])->toBe('vendor_contract1'); + + $publicId = $created['id']; + + // ---- retrieve ---- + $findRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []); + $found = $controller->find($publicId, $findRequest)->resolve($findRequest); + + expect(fleetopsVehicleContractMismatches($sent, $found))->toBe([]); + + // ---- update: every field again, with different values ---- + $updated = [ + 'name' => 'Depot Van 2', 'odometer' => 41250, 'color' => 'Silver', + 'seating_capacity' => 5, 'weight' => 2200.25, 'purchased_at' => '2026-02-03', + 'loan_first_payment' => '2026-03-15', 'loan_amount' => 31000, + 'insurance_value' => 40000, 'depreciation_rate' => 11.5, 'current_value' => 37000, + 'acquisition_cost' => 51000, 'specs' => ['doors' => 5], 'details' => ['liftgate' => false], + 'notes' => 'Regional pool', 'meta' => ['depot' => 'south'], 'skills' => ['refrigerated'], + 'max_tasks' => 30, 'time_window_start' => '07:30', 'time_window_end' => '17:30', + 'return_to_depot' => false, 'fuel_card_number' => 'FC-9002', 'online' => false, + ]; + $updateRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', $updated); + $updateResponse = $controller->update($publicId, $updateRequest)->resolve($updateRequest); + + expect(fleetopsVehicleContractMismatches($updated, $updateResponse))->toBe([]) + // A partial update leaves everything it did not name alone. + ->and($updateResponse['vin'])->toBe('1FTBW3XG8NKA00001') + ->and($updateResponse['make'])->toBe('Ford') + ->and($updateResponse['vendor_id'])->toBe('vendor_contract1'); + + // ---- query ---- + $queryRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []); + $collection = $controller->query($queryRequest)->resolve($queryRequest); + $queried = $collection[0]; + + expect(fleetopsVehicleContractMismatches($updated, $queried))->toBe([]) + // The four operations agree on the same business-field contract. + ->and(array_diff(array_keys($created), array_keys($queried)))->toBe([]); +}); + +test('vehicle status normalisation and coordinate canonicalisation are the documented transformations', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'status' => 'active', + 'latitude' => 40.7484, + 'longitude' => -73.9857, + ]); + + $payload = $controller->create($request)->resolve($request); + + // `active` is accepted and stored as `available`; the coordinates arrive as + // two scalars and come back canonically inside `location`. + expect($payload['status'])->toBe('available') + ->and($payload)->toHaveKey('location') + ->and($payload['location'])->not->toBeNull(); +}); + +test('vehicle relationships are additive: the id is new, the object keeps its shape', function () { + $connection = fleetopsVehicleContractBoot(); + $controller = new VehicleController(); + + $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'vendor' => 'vendor_contract1', + 'driver' => 'driver_contract1', + ]); + $created = $controller->create($request)->resolve($request); + + expect($created['vendor_id'])->toBe('vendor_contract1') + ->and($created['driver_id'])->toBe('driver_contract1') + ->and($created['category_id'])->toBeNull() + ->and($created['warranty_id'])->toBeNull() + ->and($created['photo_id'])->toBeNull() + // Unexpanded, the object keys stay absent — never a string under them. + ->and($created)->not->toHaveKeys(['vendor', 'driver', 'category', 'warranty', 'photo']) + ->and($created)->not->toHaveKeys(['uuid', 'public_id', 'company_uuid', 'vendor_uuid', 'photo_uuid']); + + // Expansion adds the object and leaves the identifier untouched. + $expandRequest = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor']]); + $expanded = $controller->find($created['id'], $expandRequest)->resolve($expandRequest); + + expect($expanded['vendor'])->toBeObject() + ->and($expanded['vendor_id'])->toBe('vendor_contract1') + ->and($expanded['vendor']->resolve()['id'])->toBe($expanded['vendor_id']) + ->and($expanded)->not->toHaveKey('driver'); + + // Clearing a relationship reports a null identifier. + $clearRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', ['vendor' => null]); + $cleared = $controller->update($created['id'], $clearRequest)->resolve($clearRequest); + + expect($cleared['vendor_id'])->toBeNull() + ->and($connection->table('vehicles')->count())->toBe(1); +}); + +test('the internal vehicle resource keeps its counters and its nested driver', function () { + $connection = fleetopsVehicleContractBoot(); + $controller = new VehicleController(); + + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'driver' => 'driver_contract1', + ]); + $created = $controller->create($createRequest)->resolve($createRequest); + + $vehicleUuid = $connection->table('vehicles')->where('public_id', $created['id'])->value('uuid'); + $connection->table('orders')->insert([ + ['uuid' => 'order-1', 'public_id' => 'order_1', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-1'], + ['uuid' => 'order-2', 'public_id' => 'order_2', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-2'], + ]); + + $internalRequest = fleetopsVehicleContractRequest(Request::class, 'GET', [], 'int/v1/fleet-ops/vehicles'); + $vehicle = Vehicle::where('public_id', $created['id'])->firstOrFail(); + $payload = (new Fleetbase\FleetOps\Http\Resources\v1\Vehicle($vehicle))->resolve($internalRequest); + + // The console reads these; the public contract does not, and no longer pays + // for them — `when()` evaluates a plain value argument eagerly, so both + // counters used to run a query on every public read and discard it. + expect($payload)->toHaveKeys(['uuid', 'public_id', 'assigned_orders_count', 'current_order_reference']) + ->and($payload['assigned_orders_count'])->toBe(2) + ->and($payload['uuid'])->toBe($vehicleUuid) + // Internal keeps the whenLoaded object shape it has always had. + ->and($payload['driver'])->toBeObject(); +}); diff --git a/server/tests/FleetHierarchyResourceTest.php b/server/tests/FleetHierarchyResourceTest.php index 311228952..b95ef7d4d 100644 --- a/server/tests/FleetHierarchyResourceTest.php +++ b/server/tests/FleetHierarchyResourceTest.php @@ -1,12 +1,60 @@ setAccessible(true); + + return $reflection->invoke($controller, new Request($query), FleetController::EXPANDABLE); +} + +test('fleet subfleet expansion resolves to the relation name eloquent actually has', function () { + expect(fleetopsHierarchyExpansions(['with' => 'subfleets']))->toBe(['subFleets']) + ->and(fleetopsHierarchyExpansions(['with' => ['subfleets']]))->toBe(['subFleets']) + // The camelCase spelling names the same relation. + ->and(fleetopsHierarchyExpansions(['with' => 'subFleets']))->toBe(['subFleets']); +}); + test('fleet resource expands subfleet drivers and vehicles for hierarchy payloads', function () { - $resource = file_get_contents(__DIR__ . '/../src/Http/Resources/v1/Fleet.php'); - - expect($resource) - ->toContain("if (in_array('subfleets', \$with, true))") - ->toContain("if (in_array('drivers', \$with, true))") - ->toContain("\$this->loadMissing('subFleets.drivers')") - ->toContain("if (in_array('vehicles', \$with, true))") - ->toContain("\$this->loadMissing('subFleets.vehicles')"); + expect(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers']))->toBe(['subFleets.drivers']) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.vehicles']))->toBe(['subFleets.vehicles']) + ->and(fleetopsHierarchyExpansions(['with' => ['subfleets.drivers', 'subfleets.vehicles']])) + ->toBe(['subFleets.drivers', 'subFleets.vehicles']); +}); + +test('fleet expansion refuses a nested path that would recurse or reach an unlisted relation', function () { + // Nothing under a subfleet re-opens the tree, so an expansion cannot be made + // to walk the hierarchy indefinitely. + expect(fleetopsHierarchyExpansions(['with' => 'subfleets.subfleets']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers.user']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => 'company']))->toBe([]); +}); + +test('fleet expansion input normalisation handles every malformed shape without raising', function () { + // Postman and generated clients produce all of these. None may reach + // Eloquent, and none may raise: the parameter is a convenience, not a + // reason to fail an otherwise valid request. + expect(fleetopsHierarchyExpansions(['with' => ['vendor', ['nested', 'array']]]))->toBe(['vendor']) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => '.drivers']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => ' vendor , , zone ']))->toBe(['vendor', 'zone']) + ->and(fleetopsHierarchyExpansions(['with' => 'vendor,vendor']))->toBe(['vendor']) + ->and(fleetopsHierarchyExpansions(['with' => '']))->toBe([]) + ->and(fleetopsHierarchyExpansions([]))->toBe([]) + // `expand` is the documented alias and takes over when `with` is empty. + ->and(fleetopsHierarchyExpansions(['with' => '', 'expand' => 'vendor']))->toBe(['vendor']); }); diff --git a/server/tests/Unit/Http/Resources/FleetResourceTest.php b/server/tests/Unit/Http/Resources/FleetResourceTest.php index 1f48b3970..91a4c7aba 100644 --- a/server/tests/Unit/Http/Resources/FleetResourceTest.php +++ b/server/tests/Unit/Http/Resources/FleetResourceTest.php @@ -1,5 +1,6 @@ setAccessible(true); + $reflection->invoke($controller, $request, FleetController::EXPANDABLE); + + return (new FleetResource($fleet))->resolve($request); +} + test('fleet resource preloads requested subfleet driver and vehicle relations', function () { fleetopsFleetResourceBoot(); - $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); - $request = Request::create('/v1/fleets/fleet_parentone', 'GET', ['with' => ['subfleets', 'drivers', 'vehicles']]); - - $resolved = (new FleetResource($fleet))->resolve($request); + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + $resolved = fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets', 'drivers', 'vehicles']]); + // `subFleets` is the relation Eloquent actually has. The public name differs + // from it only in case, and PHP method calls are case-insensitive, so + // `load('subfleets')` used to succeed and store a *second* copy under the + // mis-cased key — which `whenLoaded('subFleets')` then could not see. expect($resolved['name'])->toBe('Parent Fleet') - ->and($fleet->relationLoaded('subfleets'))->toBeTrue() - ->and($fleet->relationLoaded('subFleets'))->toBeTrue(); + ->and($fleet->relationLoaded('subFleets'))->toBeTrue() + ->and($resolved)->toHaveKey('subfleets'); $subFleet = $fleet->getRelation('subFleets')->first(); + + // Asking for subfleets alongside drivers and vehicles still nests them, as + // the released contract did. expect($subFleet)->not->toBeNull() ->and($subFleet->relationLoaded('drivers'))->toBeTrue() ->and($subFleet->relationLoaded('vehicles'))->toBeTrue() ->and($subFleet->drivers)->toHaveCount(1) ->and($subFleet->vehicles)->toHaveCount(1); }); + +test('fleet resource resolves subfleets on its own, which the mis-cased load never did', function () { + fleetopsFleetResourceBoot(); + + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + $resolved = fleetopsFleetResourcePayload($fleet, ['with' => 'subfleets']); + + // Before the mapping, this returned no `subfleets` key at all: the load + // landed under `subfleets` and the resource looked for `subFleets`. + expect($resolved)->toHaveKey('subfleets') + ->and($fleet->relationLoaded('subFleets'))->toBeTrue(); +}); + +test('fleet resource accepts the explicit nested expansion spelling', function () { + fleetopsFleetResourceBoot(); + + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets.drivers']]); + + $subFleet = $fleet->getRelation('subFleets')->first(); + + expect($subFleet->relationLoaded('drivers'))->toBeTrue() + ->and($subFleet->relationLoaded('vehicles'))->toBeFalse(); +}); From d30348394f694b07eb8e3755bbb78362f8635f23 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 15:38:14 +0800 Subject: [PATCH 04/11] ci(temporary): pin the contract run to the postman PR commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates this branch against fleetbase/postman@0fcdff3, the commit that documents and asserts these endpoints. The collection on postman main predates every endpoint added here, so a run against it proves only that nothing already released regressed. Reverted by the next commit — release-bound code must not stay pinned to an unmerged branch. --- .github/workflows/postman.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 702cd6437..0acf484e4 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -29,6 +29,12 @@ jobs: with: collections: "Fleetbase API" build-from-source: false + # TEMPORARY — remove before merge. + # Pins the collection to the exact commit on fleetbase/postman#59 so this + # PR is validated against the contract that documents it, rather than + # against a collection on main that predates every endpoint added here. + # The revert commit that follows this one restores `main`. + postman-ref: 0fcdff3835bf208e3468bfe9fe72570e9d7cf5f2 # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. From 9f014f9cea4fcb462351fd5e09ed6edf749dce16 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 15:55:16 +0800 Subject: [PATCH 05/11] fix: map expansions on retrieve, where the request is never injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-repository contract run answered 500 for `GET /v1/vehicles/{id}?with[]=vendor&with[]=not_a_relation`: "Call to undefined relationship [not_a_relation]". Both retrieve actions took the request as an optional parameter — Vehicle as `?Request $request = null`, Driver not at all — and Laravel's controller dispatcher skips resolving a type-hinted dependency that carries a default. It arrived null, the expansions were never mapped or allowlisted, and the raw name reached Eloquent. Retrieve is the endpoint most likely to be handed a stale relation name, and it was the only one where the allowlist did not run. Both now read the container's request. Covered by a test that drives the real find() with an unsupported name and asserts the response is intact. --- .../Controllers/Api/v1/DriverController.php | 5 +++++ .../Controllers/Api/v1/VehicleController.php | 11 +++++++--- .../Http/Api/VehiclePublicContractTest.php | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index a51027bf5..8711e0e1a 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -248,6 +248,11 @@ public function query(Request $request) */ public function find($id) { + // Retrieve carries no Request parameter, so the container's is used — + // otherwise an unsupported `with` on this endpoint would reach Eloquent + // unmapped. + $this->applyPublicExpansions(request(), static::EXPANDABLE); + // find for the driver try { $driver = $this->findDriver($id, ['user', 'vehicle', 'vendor', 'currentJob']); diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 2cf1a07f6..55d53e237 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -198,9 +198,14 @@ public function query(Request $request) */ public function find($id, ?Request $request = null) { - if ($request instanceof Request) { - $this->applyPublicExpansions($request, static::EXPANDABLE); - } + // Falls back to the container's request rather than trusting injection: + // the parameter carries a default, and Laravel's controller dispatcher + // skips resolving a type-hinted dependency that has one. It arrives null, + // the expansions are never mapped, and an unsupported name reaches + // Eloquent — a 500 for a typo, on the one endpoint most likely to be + // handed one. + $request = $request instanceof Request ? $request : request(); + $this->applyPublicExpansions($request, static::EXPANDABLE); // find for the vehicle try { diff --git a/server/tests/Feature/Http/Api/VehiclePublicContractTest.php b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php index 52be7c4ba..15cf72cdc 100644 --- a/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +++ b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php @@ -432,3 +432,23 @@ function fleetopsVehicleContractMismatches(array $sent, array $payload): array // Internal keeps the whenLoaded object shape it has always had. ->and($payload['driver'])->toBeObject(); }); + +test('an unsupported expansion on retrieve cannot reach eloquent', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', ['make' => 'Ford']); + $created = $controller->create($createRequest)->resolve($createRequest); + + // Retrieve is the endpoint a hand-written client is most likely to hand a + // stale relation name. The request parameter carries a default, and + // Laravel's dispatcher skips injecting a type-hinted dependency that has + // one — so the controller reads the container's request instead. Without + // that, `not_a_relation` reached load() and answered 500. + $request = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor', 'not_a_relation']]); + $payload = $controller->find($created['id'])->resolve($request); + + expect($payload['id'])->toBe($created['id']) + ->and($payload)->not->toHaveKey('not_a_relation') + ->and($request->input('with'))->toBe(['vendor']); +}); From a249e7df558402d2aa08062ea20780471339e219 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 15:55:45 +0800 Subject: [PATCH 06/11] ci(temporary): repin the contract run to the corrected postman commit Still reverted before merge. --- .github/workflows/postman.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 0acf484e4..cc969f82e 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -34,7 +34,7 @@ jobs: # PR is validated against the contract that documents it, rather than # against a collection on main that predates every endpoint added here. # The revert commit that follows this one restores `main`. - postman-ref: 0fcdff3835bf208e3468bfe9fe72570e9d7cf5f2 + postman-ref: 91e72feb908ecbe3b8a7484d812825fbc0ca7a83 # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. From 3bb1385d296be84f59c82ea72a16cf1758c13db4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 16:09:30 +0800 Subject: [PATCH 07/11] Revert "ci(temporary): repin the contract run to the corrected postman commit" This reverts commit a249e7df558402d2aa08062ea20780471339e219. --- .github/workflows/postman.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index cc969f82e..0acf484e4 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -34,7 +34,7 @@ jobs: # PR is validated against the contract that documents it, rather than # against a collection on main that predates every endpoint added here. # The revert commit that follows this one restores `main`. - postman-ref: 91e72feb908ecbe3b8a7484d812825fbc0ca7a83 + postman-ref: 0fcdff3835bf208e3468bfe9fe72570e9d7cf5f2 # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. From d66d64b72a14926f8b95ce4e6aded5a0c735e184 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 5 Sep 2026 16:09:30 +0800 Subject: [PATCH 08/11] Revert "ci(temporary): pin the contract run to the postman PR commit" This reverts commit d30348394f694b07eb8e3755bbb78362f8635f23. --- .github/workflows/postman.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 0acf484e4..702cd6437 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -29,12 +29,6 @@ jobs: with: collections: "Fleetbase API" build-from-source: false - # TEMPORARY — remove before merge. - # Pins the collection to the exact commit on fleetbase/postman#59 so this - # PR is validated against the contract that documents it, rather than - # against a collection on main that predates every endpoint added here. - # The revert commit that follows this one restores `main`. - postman-ref: 0fcdff3835bf208e3468bfe9fe72570e9d7cf5f2 # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. From 094fca531329c20e9e30111c10a7e9e18a91ef66 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 6 Sep 2026 11:31:54 +0800 Subject: [PATCH 09/11] fix: treat an empty relationship as absent instead of raising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConvertEmptyStringsToNull` turns `"driver": ""` into null before the controller sees it, and `has('driver')` is still true afterwards — `has()` reports that the key is present, not that it holds anything. A null therefore reaches these lookups whenever a client serialises an unselected field as an empty string. That was harmless for years: the lookups were plain queries, `where('public_id', null)` matched nothing, and the caller's `if ($driver)` skipped the assignment. 2c7de8fb extracted those queries into helpers and typed them `string` while making only the return nullable, so "no driver" became a TypeError. Note that `vehicle`, assigned three lines below `driver` via `Utils::getUuid()`, never broke — the regression belongs to the extraction, not to the pattern. Widen the eight seams fed directly from request input. Every body is unchanged and each was already null-safe, so this adds no statements and no new behaviour: a widened parameter is strictly more permissive, and the only calls that behave differently are the ones that used to crash. The `has()`/`empty()` guards on update paths are deliberately untouched. Switching them to `filled()` would read as the tidier fix and would silently break clearing — `VehicleController::update` unassigns a driver on an empty value, and `PUT {"vendor": ""}` must keep nulling `vendor_id`. Also mark `service_area` and `zone` nullable on service rate creation. Both are optional, so an empty one has to read as absent rather than draw "the selected service area is invalid". The regression test derives its seam list by scanning for `$this->seam($request->input(...))` rather than pinning today's eight, so a future extraction that types a new seam `string` fails at the commit that introduces it. Exemptions have to name the call-site guard that excludes null, and the scan asserts it actually matched something so it cannot pass vacuously. --- .../Controllers/Api/v1/EntityController.php | 2 +- .../Api/v1/FuelReportController.php | 2 +- .../Controllers/Api/v1/IssueController.php | 2 +- .../Controllers/Api/v1/OrderController.php | 2 +- .../Api/v1/ServiceAreaController.php | 2 +- .../Api/v1/TrackingStatusController.php | 2 +- .../Controllers/Api/v1/VehicleController.php | 2 +- .../Controllers/Api/v1/ZoneController.php | 2 +- .../Requests/CreateServiceRateRequest.php | 9 +- .../ApiEntityControllerContractsTest.php | 2 +- .../ApiFuelReportControllerContractsTest.php | 2 +- .../tests/ApiIssueControllerContractsTest.php | 2 +- .../tests/ApiOrderControllerContractsTest.php | 2 +- ...ServiceAreaZoneControllerContractsTest.php | 4 +- ...iTrackingStatusControllerContractsTest.php | 2 +- .../ApiVehicleControllerContractsTest.php | 2 +- .../Http/Api/NullRelationshipInputTest.php | 191 ++++++++++++++++++ server/tests/RequestContractsTest.php | 8 +- .../VehicleControllerHelperContractsTest.php | 2 +- 19 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 server/tests/Feature/Http/Api/NullRelationshipInputTest.php diff --git a/server/src/Http/Controllers/Api/v1/EntityController.php b/server/src/Http/Controllers/Api/v1/EntityController.php index 0a3d166e6..bc8203920 100644 --- a/server/src/Http/Controllers/Api/v1/EntityController.php +++ b/server/src/Http/Controllers/Api/v1/EntityController.php @@ -271,7 +271,7 @@ protected function singularize(?string $value): ?string return $value ? Utils::singularize($value) : null; } - protected function findPayloadByPublicId(string $publicId): ?Payload + protected function findPayloadByPublicId(?string $publicId): ?Payload { return Payload::where('public_id', $publicId)->first(); } diff --git a/server/src/Http/Controllers/Api/v1/FuelReportController.php b/server/src/Http/Controllers/Api/v1/FuelReportController.php index 3d316cf15..91b31f8e1 100644 --- a/server/src/Http/Controllers/Api/v1/FuelReportController.php +++ b/server/src/Http/Controllers/Api/v1/FuelReportController.php @@ -157,7 +157,7 @@ public function delete($id) return $this->deletedFuelReportResource($fuelReport); } - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { return Driver::findRecordOrFail($id); } diff --git a/server/src/Http/Controllers/Api/v1/IssueController.php b/server/src/Http/Controllers/Api/v1/IssueController.php index 3d6155f90..55f6d7e39 100644 --- a/server/src/Http/Controllers/Api/v1/IssueController.php +++ b/server/src/Http/Controllers/Api/v1/IssueController.php @@ -158,7 +158,7 @@ public function delete($id) return $this->deletedIssueResource($issue); } - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { return Driver::findRecordOrFail($id); } diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 152479dec..bc4054d9a 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -1873,7 +1873,7 @@ protected function newPayload(): Payload return new Payload(); } - protected function findDriverByPublicId(string $publicId): ?Driver + protected function findDriverByPublicId(?string $publicId): ?Driver { return Driver::where(['public_id' => $publicId, 'company_uuid' => session('company')])->first(); } diff --git a/server/src/Http/Controllers/Api/v1/ServiceAreaController.php b/server/src/Http/Controllers/Api/v1/ServiceAreaController.php index f8726fd7c..b82fe4185 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceAreaController.php @@ -218,7 +218,7 @@ protected function createBorderFromPoint(Point $point, int $radius) return ServiceArea::createMultiPolygonFromPoint($point, $radius); } - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { return Utils::getUuid('service_areas', $where); } diff --git a/server/src/Http/Controllers/Api/v1/TrackingStatusController.php b/server/src/Http/Controllers/Api/v1/TrackingStatusController.php index 6a80f23a6..153c85d1c 100644 --- a/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +++ b/server/src/Http/Controllers/Api/v1/TrackingStatusController.php @@ -187,7 +187,7 @@ protected function getTrackingNumberUuid(string $table, array $where): ?string return Utils::getUuid($table, $where); } - protected function getOrderTrackingNumberUuid(string $orderId): ?string + protected function getOrderTrackingNumberUuid(?string $orderId): ?string { return Order::where('public_id', $orderId)->value('tracking_number_uuid'); } diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 55d53e237..ffaff5dcd 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -492,7 +492,7 @@ protected function findVehicle(string $id): Vehicle return Vehicle::findRecordOrFail($id); } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { return Driver::findRecordOrFail($id); } diff --git a/server/src/Http/Controllers/Api/v1/ZoneController.php b/server/src/Http/Controllers/Api/v1/ZoneController.php index dba4e5af2..51adfe76c 100644 --- a/server/src/Http/Controllers/Api/v1/ZoneController.php +++ b/server/src/Http/Controllers/Api/v1/ZoneController.php @@ -212,7 +212,7 @@ protected function radiusFromRequest(Request $request): int return (int) $request->input('radius', 500); } - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { return Utils::getUuid('service_areas', $where); } diff --git a/server/src/Http/Requests/CreateServiceRateRequest.php b/server/src/Http/Requests/CreateServiceRateRequest.php index 304eafe4e..41122c58f 100644 --- a/server/src/Http/Requests/CreateServiceRateRequest.php +++ b/server/src/Http/Requests/CreateServiceRateRequest.php @@ -25,8 +25,13 @@ public function rules(): array return [ 'service_name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], 'service_type' => [Rule::requiredIf($this->isMethod('POST')), 'string'], - 'service_area' => [Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')], - 'zone' => [Rule::exists('zones', 'public_id')->whereNull('deleted_at')], + // `nullable` so an empty relationship is treated as absent rather than + // as an invalid one. Both are optional, but without it `exists` runs + // against the null that ConvertEmptyStringsToNull produced and answers + // "The selected service area is invalid" for a field the caller simply + // did not fill in. + 'service_area' => ['nullable', Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')], + 'zone' => ['nullable', Rule::exists('zones', 'public_id')->whereNull('deleted_at')], 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo,parcel'], 'currency' => ['required', 'size:3'], 'base_fee' => ['numeric'], diff --git a/server/tests/ApiEntityControllerContractsTest.php b/server/tests/ApiEntityControllerContractsTest.php index 637274972..4f6efc5b8 100644 --- a/server/tests/ApiEntityControllerContractsTest.php +++ b/server/tests/ApiEntityControllerContractsTest.php @@ -43,7 +43,7 @@ protected function singularize(?string $value): ?string return $value === 'contacts' ? 'contact' : null; } - protected function findPayloadByPublicId(string $publicId): ?Payload + protected function findPayloadByPublicId(?string $publicId): ?Payload { $this->payload?->setAttribute('lookup_id', $publicId); diff --git a/server/tests/ApiFuelReportControllerContractsTest.php b/server/tests/ApiFuelReportControllerContractsTest.php index 3083a5fc3..ec22cc016 100644 --- a/server/tests/ApiFuelReportControllerContractsTest.php +++ b/server/tests/ApiFuelReportControllerContractsTest.php @@ -17,7 +17,7 @@ class FleetOpsApiFuelReportControllerProbe extends FuelReportController public bool $driverNotFound = false; public bool $fuelReportNotFound = false; - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); diff --git a/server/tests/ApiIssueControllerContractsTest.php b/server/tests/ApiIssueControllerContractsTest.php index 3492a80e6..167c1decf 100644 --- a/server/tests/ApiIssueControllerContractsTest.php +++ b/server/tests/ApiIssueControllerContractsTest.php @@ -17,7 +17,7 @@ class FleetOpsApiIssueControllerProbe extends IssueController public bool $driverNotFound = false; public bool $issueNotFound = false; - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index fb6860b64..cd5c8a4ff 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -80,7 +80,7 @@ protected function newPayload(): Payload return $this->payload = new FleetOpsApiOrderPayloadFake(); } - protected function findDriverByPublicId(string $publicId): ?Fleetbase\FleetOps\Models\Driver + protected function findDriverByPublicId(?string $publicId): ?Fleetbase\FleetOps\Models\Driver { $this->driver ??= new FleetOpsApiOrderDriverFake(); $this->driver->setRawAttributes([ diff --git a/server/tests/ApiServiceAreaZoneControllerContractsTest.php b/server/tests/ApiServiceAreaZoneControllerContractsTest.php index 8809e21a5..bd32bd95b 100644 --- a/server/tests/ApiServiceAreaZoneControllerContractsTest.php +++ b/server/tests/ApiServiceAreaZoneControllerContractsTest.php @@ -23,7 +23,7 @@ class FleetOpsApiServiceAreaControllerProbe extends ApiServiceAreaController public bool $notFound = false; public bool $createThrows = false; - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { $this->uuidLookups[] = [$publicId, $where]; @@ -116,7 +116,7 @@ class FleetOpsApiZoneControllerProbe extends ApiZoneController public mixed $queryResults = null; public bool $notFound = false; - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { $this->uuidLookups[] = [$publicId, $where]; diff --git a/server/tests/ApiTrackingStatusControllerContractsTest.php b/server/tests/ApiTrackingStatusControllerContractsTest.php index e4682006b..f925820c4 100644 --- a/server/tests/ApiTrackingStatusControllerContractsTest.php +++ b/server/tests/ApiTrackingStatusControllerContractsTest.php @@ -32,7 +32,7 @@ protected function getTrackingNumberUuid(string $table, array $where): ?string return 'tracking-number-uuid'; } - protected function getOrderTrackingNumberUuid(string $orderId): ?string + protected function getOrderTrackingNumberUuid(?string $orderId): ?string { $this->orderLookups[] = $orderId; diff --git a/server/tests/ApiVehicleControllerContractsTest.php b/server/tests/ApiVehicleControllerContractsTest.php index 88c77f948..92a05837e 100644 --- a/server/tests/ApiVehicleControllerContractsTest.php +++ b/server/tests/ApiVehicleControllerContractsTest.php @@ -70,7 +70,7 @@ protected function findVehicle(string $id): Vehicle return $this->vehicle; } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); diff --git a/server/tests/Feature/Http/Api/NullRelationshipInputTest.php b/server/tests/Feature/Http/Api/NullRelationshipInputTest.php new file mode 100644 index 000000000..05ccd1de4 --- /dev/null +++ b/server/tests/Feature/Http/Api/NullRelationshipInputTest.php @@ -0,0 +1,191 @@ +has('driver')` is still true — `has()` + * means the key is present, not that it holds anything. Every public + * relationship input therefore arrives as null sooner or later, usually from a + * form that serialises an unselected dropdown as an empty string. + * + * That was fine for years because the lookups were plain queries: + * `where('public_id', null)` matches nothing and the caller's `if ($driver)` + * skips. It broke when a coverage pass extracted one of those queries into + * `findDriverByPublicId(string $publicId)` — the *return* type was made + * nullable, the parameter was not — turning "no driver" into a TypeError on + * `POST /v1/orders`. + * + * The static test below is the one that would have caught it: it holds the + * property rather than the instance, so the next extraction cannot reintroduce + * the class. + */ +function fleetopsNullRelationshipBoot(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + foreach (['drivers', 'users', 'payloads', 'orders', 'service_areas', 'entities', 'waypoints', 'places'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'tracking_number_uuid', 'payload_uuid', 'place_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-uuid']); + $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_real01', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']); + $connection->table('payloads')->insert(['uuid' => 'payload-uuid', 'public_id' => 'payload_real01', 'company_uuid' => 'company-uuid']); + $connection->table('orders')->insert(['uuid' => 'order-uuid', 'public_id' => 'order_real01', 'company_uuid' => 'company-uuid', 'tracking_number_uuid' => 'tracking-uuid']); + $connection->table('service_areas')->insert(['uuid' => 'sa-uuid', 'public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']); + + return $connection; +} + +function fleetopsInvokeSeam(object $controller, string $method, ...$arguments) +{ + $reflection = new ReflectionMethod($controller, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($controller, ...$arguments); +} + +test('every request-fed lookup seam answers rather than raising when handed nothing', function () { + fleetopsNullRelationshipBoot(); + + // Each of these is reached from `$request->input('')`. A null + // means the caller sent an empty relationship, which is a question with an + // answer — "there is no such record" — not a programming error. + expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', null))->toBeNull() + ->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', null))->toBeNull() + ->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', null))->toBeNull() + ->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull() + ->and(fleetopsInvokeSeam(new ZoneController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull(); + + // Issue and FuelReport reach their seam only from `create`, where `driver` + // is `required` — validation answers 422 long before a null could arrive. + // Widening them is defence-in-depth for a future call site, so the claim + // here is only the one that matters: whatever they answer, it is not a + // TypeError. A TypeError would escape this block and fail the test. + foreach ([new IssueController(), new FuelReportController()] as $controller) { + try { + fleetopsInvokeSeam($controller, 'findDriverRecord', null); + } catch (ModelNotFoundException) { + // "No such driver" — the call sites already translate this into a 404. + } + } +}); + +test('a real identifier still resolves through the widened seams', function () { + fleetopsNullRelationshipBoot(); + + // Widening the parameter must not have widened the lookup: a present + // identifier resolves exactly as before, and one that does not exist still + // resolves to nothing. + expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_real01')->uuid)->toBe('driver-uuid') + ->and(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_missing'))->toBeNull() + ->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', 'payload_real01')->uuid)->toBe('payload-uuid') + ->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', 'order_real01'))->toBe('tracking-uuid') + ->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', 'service_area_r1', ['public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']))->toBe('sa-uuid'); +}); + +test('no public api lookup seam fed from the request refuses a null identifier', function () { + // The systemic guard. Rather than pinning the eight seams that exist today, + // it derives them: every call of the form `$this->someSeam($request->input(...))` + // in the public v1 controllers, checked for a nullable first parameter. + // + // An extraction that types a new seam `string` fails here, at the commit + // that introduces it, instead of in production on an empty dropdown. + // Exempt only where the *call site* already excludes null, and say which + // guard does it — an exemption without a named guard is how this test would + // rot into a rubber stamp. + $exempt = [ + // Not an identifier lookup — it geocodes a free-text address, and a null + // address has no meaning to geocode. Its call sites guard with + // `$request->isString(...)`, which a null can never satisfy. + 'PlaceController::createPlaceFromGeocodingLookup', + // Both call sites are behind `$request->isArray('payload')`. A payload + // sent as `""` fails that guard and is skipped, never unpacked. + 'OrderController::payloadShapeFromArray', + ]; + + $offenders = []; + $inspected = []; + + foreach (glob(dirname(__DIR__, 4) . '/src/Http/Controllers/Api/v1/*.php') as $path) { + $source = file_get_contents($path); + $controller = basename($path, '.php'); + + preg_match_all('/\$this->([A-Za-z_]+)\(\s*\$request->(?:input|or)\(/', $source, $matches); + + foreach (array_unique($matches[1]) as $seam) { + if (in_array($controller . '::' . $seam, $exempt, true)) { + continue; + } + + if (!preg_match('/(?:protected|public|private) function ' . preg_quote($seam, '/') . '\(([^),]*)/', $source, $signature)) { + continue; + } + + $firstParameter = trim($signature[1]); + $inspected[] = $controller . '::' . $seam; + + // `mixed`, an untyped parameter, and anything explicitly nullable all + // accept null already; only a narrow non-nullable type is a problem. + $accepts = $firstParameter === '' + || str_starts_with($firstParameter, '?') + || str_starts_with($firstParameter, '$') + || str_contains($firstParameter, 'null') + || str_starts_with($firstParameter, 'mixed'); + + if (!$accepts) { + $offenders[] = $controller . '::' . $seam . '(' . $firstParameter . ')'; + } + } + } + + // A guard that scans nothing passes for the wrong reason, so make the scan + // prove itself: the seam this bug was reported against has to be among what + // it looked at. If the glob or the pattern ever stops matching, this fails + // instead of going quietly green. + expect($inspected)->toContain('OrderController::findDriverByPublicId') + ->and($offenders)->toBe([], 'these seams are handed a request value and would raise a TypeError on an empty relationship: ' . implode(', ', $offenders)); +}); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 075f0efdb..9cda5fd89 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -1041,7 +1041,13 @@ public function parameter($key, $default = null) ->and(ruleStrings($rateRules['peak_hours_calculation_method']))->toContain('required', 'in:percentage,flat') ->and(ruleStrings($rateRules['peak_hours_percent']))->toContain('required', 'integer') ->and(ruleStrings($rateRules['peak_hours_start']))->toContain('required', 'date_format:H:i') - ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i'); + ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i') + // Both relationships are optional, so an empty one has to read as + // absent. Without `nullable` the `exists` rule runs against the null + // that ConvertEmptyStringsToNull produced and answers "the selected + // service area is invalid" for a field the caller left blank. + ->and(ruleStrings($rateRules['service_area']))->toContain('nullable') + ->and(ruleStrings($rateRules['zone']))->toContain('nullable'); // A street address alone satisfies the name requirement on create, and // updates never require either field since the record already exists diff --git a/server/tests/VehicleControllerHelperContractsTest.php b/server/tests/VehicleControllerHelperContractsTest.php index 3f2c63da4..67a150793 100644 --- a/server/tests/VehicleControllerHelperContractsTest.php +++ b/server/tests/VehicleControllerHelperContractsTest.php @@ -39,7 +39,7 @@ protected function resolveVehicle(string $id): ?Vehicle return $this->vehicle; } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { $this->driverLookups[] = $id; From 5eb30954fcb2065a9216ac37417ff2b44ffac9a7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 6 Sep 2026 12:24:39 +0800 Subject: [PATCH 10/11] ci(temporary): pin contract run to current Postman PR --- .github/workflows/postman.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 702cd6437..f2405a65a 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -29,6 +29,10 @@ jobs: with: collections: "Fleetbase API" build-from-source: false + # TEMPORARY — remove after this validation run. + # Pin to the exact current head of fleetbase/postman#59 so this PR runs + # the contract additions for empty public relationship inputs. + postman-ref: f64b1f741be69678f5657d694a6a861cfc53ccaa # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. From 57e9950fce4ca876f5d49b0e248c112eade4135e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 6 Sep 2026 12:38:57 +0800 Subject: [PATCH 11/11] Revert "ci(temporary): pin contract run to current Postman PR" This reverts commit 5eb30954fcb2065a9216ac37417ff2b44ffac9a7. --- .github/workflows/postman.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index f2405a65a..702cd6437 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -29,10 +29,6 @@ jobs: with: collections: "Fleetbase API" build-from-source: false - # TEMPORARY — remove after this validation run. - # Pin to the exact current head of fleetbase/postman#59 so this PR runs - # the contract additions for empty public relationship inputs. - postman-ref: f64b1f741be69678f5657d694a6a861cfc53ccaa # Without this the run tests the version of fleetbase/fleetops-api baked into the # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container.