From 6e5434ad421a80f60466b6addf6bd03904bebdda Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 4 Sep 2026 18:59:35 +0800 Subject: [PATCH 1/2] 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 2/2] 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([]);