diff --git a/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php b/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php new file mode 100644 index 000000000..f8f1ffad0 --- /dev/null +++ b/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php @@ -0,0 +1,129 @@ + + */ + private array $pivots = [ + 'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'], + 'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'], + ]; + + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + foreach ($this->pivots as $table => [$memberColumn, $indexName]) { + if (!Schema::hasTable($table)) { + continue; + } + + $this->removeDuplicateMemberships($table, $memberColumn); + + if (!$this->indexExists($table, $indexName)) { + Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) { + $blueprint->unique(['fleet_uuid', $memberColumn], $indexName); + }); + } + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach ($this->pivots as $table => [$memberColumn, $indexName]) { + if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) { + Schema::table($table, function (Blueprint $blueprint) use ($indexName) { + $blueprint->dropUnique($indexName); + }); + } + } + } + + /** + * Collapse every duplicated pair down to the one row worth keeping. + * + * An active row wins over a tombstone, because that is the membership the + * fleet actually has today. Among equals the lowest id wins, so the outcome + * is deterministic and a re-run is a no-op. When every row for a pair is + * soft-deleted, one is still kept — removing them all would turn a later + * re-assignment into a new row and lose the original membership's history. + * + * Only redundant pivot rows are removed. No fleet, vehicle or driver is + * touched, and no surviving membership changes state. + */ + private function removeDuplicateMemberships(string $table, string $memberColumn): void + { + $duplicatePairs = DB::table($table) + ->select('fleet_uuid', $memberColumn) + ->whereNotNull('fleet_uuid') + ->whereNotNull($memberColumn) + ->groupBy('fleet_uuid', $memberColumn) + ->havingRaw('COUNT(*) > 1') + ->get(); + + foreach ($duplicatePairs as $pair) { + $pair = (array) $pair; + + $rows = DB::table($table) + ->where('fleet_uuid', $pair['fleet_uuid']) + ->where($memberColumn, $pair[$memberColumn]) + // Active rows first, then oldest first. + ->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('id') + ->pluck('id'); + + $redundant = $rows->slice(1)->values(); + + if ($redundant->isNotEmpty()) { + DB::table($table)->whereIn('id', $redundant->all())->delete(); + } + } + } + + private function indexExists(string $table, string $index): bool + { + $database = DB::connection()->getDatabaseName(); + + return DB::table('information_schema.statistics') + ->where('table_schema', $database) + ->where('table_name', $table) + ->where('index_name', $index) + ->exists(); + } +}; diff --git a/server/src/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/Concerns/ResolvesPublicExpansions.php b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php new file mode 100644 index 000000000..bf6605c57 --- /dev/null +++ b/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php @@ -0,0 +1,187 @@ +load(...)`, with no allowlist and no mapping. Two things + * follow from that. A caller can name any string and reach `load()`, and an + * unknown one raises `RelationNotFoundException` — a 500 for a typo. And the + * public name is not always the relation name: `?with=subfleets` normalises to + * `subfleets`, while the relation is `subFleets`, so the documented expansion + * has never worked. + * + * Rewriting the request once, up front, fixes both at the only point where the + * query builder and the resource are guaranteed to agree on the list. + */ +trait ResolvesPublicExpansions +{ + /** + * Resolve the requested expansions and rewrite the request with them. + * + * @param array $allowed public name => Eloquent relation name + * + * @return array the resolved relation names, in request order + */ + protected function applyPublicExpansions(Request $request, array $allowed): array + { + $resolved = $this->resolvePublicExpansions($request, $allowed); + + // Both keys are rewritten: Core reads `with` or `expand`, whichever is + // present, so leaving the other one holding raw input would put the + // unmapped name back in front of load(). + $request->merge(['with' => $resolved]); + + if ($request->exists('expand')) { + $request->merge(['expand' => $resolved]); + } + + return $resolved; + } + + /** + * @param array $allowed + * + * @return array + */ + protected function resolvePublicExpansions(Request $request, array $allowed): array + { + $requested = $this->publicExpansionInput($request); + $resolved = []; + + foreach ($requested as $relation) { + $mapped = $this->mapPublicExpansion($relation, $allowed); + + // An unsupported expansion is ignored rather than rejected. A 422 + // would turn a harmless unknown name into a failed request for + // every generated client that sends a relation this version does + // not have yet, and the alternative — passing it through — is a + // 500 from Eloquent. + if ($mapped !== null && !in_array($mapped, $resolved, true)) { + $resolved[] = $mapped; + } + } + + return $resolved; + } + + /** + * Every accepted input shape flattened to one list of trimmed, non-empty paths. + * + * `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand` + * alias of each all arrive here and leave identical. + * + * @return array + */ + protected function publicExpansionInput(Request $request): array + { + $raw = $request->input('with'); + + if ($raw === null || $raw === '' || $raw === []) { + $raw = $request->input('expand'); + } + + if ($raw === null) { + return []; + } + + $values = []; + foreach (is_array($raw) ? $raw : [$raw] as $entry) { + if (is_array($entry)) { + continue; + } + + foreach (explode(',', (string) $entry) as $part) { + $part = trim($part); + + if ($part !== '') { + $values[] = $part; + } + } + } + + return $values; + } + + /** + * Map one public path onto its Eloquent relation path, or null if unsupported. + * + * Each dotted segment is resolved against the allowlist for the level it sits + * at, so `subfleets.drivers` is checked as `subfleets` then as `drivers` — + * a nested path cannot smuggle in a relation the top level would refuse. + * + * @param array $allowed + */ + protected function mapPublicExpansion(string $relation, array $allowed): ?string + { + $segments = explode('.', $relation); + $mapped = []; + + foreach ($segments as $index => $segment) { + $segment = trim($segment); + + if ($segment === '') { + return null; + } + + $candidates = $index === 0 ? $allowed : $this->nestedExpansionAllowList(); + $key = $this->matchExpansionKey($segment, $candidates); + + if ($key === null) { + return null; + } + + $mapped[] = $candidates[$key]; + } + + return implode('.', $mapped); + } + + /** + * Relations reachable underneath an already-allowed relation. + * + * Deliberately shallow. Subfleets carry drivers and vehicles, which is the + * documented nested case; nothing here re-opens the tree, so + * `subfleets.subfleets` is refused and an expansion cannot recurse. + * + * @return array + */ + protected function nestedExpansionAllowList(): array + { + return [ + 'drivers' => 'drivers', + 'vehicles' => 'vehicles', + ]; + } + + /** + * Match a requested segment against the allowlist, spelling-insensitively. + * + * `service_area`, `serviceArea` and `ServiceArea` all name the same relation, + * and so do `subfleets`, `subFleets` and `sub_fleets`. Comparing on + * case-folded, separator-stripped forms accepts every spelling a client or a + * generated SDK might produce without widening what is actually allowed. + * + * @param array $candidates + */ + private function matchExpansionKey(string $segment, array $candidates): ?string + { + foreach ($candidates as $key => $relation) { + if ($this->expansionLookupKey($key) === $this->expansionLookupKey($segment)) { + return $key; + } + } + + return null; + } + + private function expansionLookupKey(string $value): string + { + return strtolower(str_replace(['_', '-'], '', $value)); + } +} diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 76e22c14e..8711e0e1a 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -6,6 +6,9 @@ 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\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest; use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest; use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest; @@ -16,6 +19,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 +44,23 @@ class DriverController extends Controller { use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + use ResolvesFleetOpsApiResources; + use ResolvesPublicExpansions; + + /** + * Public expansion name => Eloquent relation name. + * + * `user` and `company` are deliberately absent. Both are already published + * as public-id strings, and Navigator interpolates `driver.user` directly + * into a socket channel name — expanding either would retype a released + * field and break it silently. + */ + public const EXPANDABLE = [ + 'vehicle' => 'vehicle', + 'vendor' => 'vendor', + 'current_job' => 'currentJob', + 'fleets' => 'fleets', + ]; /** * Creates a new Fleetbase Driver resource. @@ -48,11 +69,7 @@ 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'); + $this->applyPublicExpansions($request, static::EXPANDABLE); // get user details for driver $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']); @@ -65,6 +82,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 +116,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 +135,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]); } } @@ -151,6 +160,8 @@ public function create(CreateDriverRequest $request) */ public function update($id, UpdateDriverRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the driver try { $driver = $this->findDriver($id, ['user']); @@ -164,7 +175,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 @@ -173,7 +188,10 @@ public function update($id, UpdateDriverRequest $request) * password. Changing a password is its own operation with its own * proof: see changePassword(), forgotPassword() and resetPassword(). */ - $userDetails = $request->only(['name', 'email', 'phone']); + // `timezone` is accepted and documented on update, but was never copied + // to the linked user — the request validated, answered 200, and dropped + // it. The driver's own record has no timezone column; the user's does. + $userDetails = $request->only(['name', 'email', 'phone', 'timezone']); // update driver user details $driverUser = $driver->getUser(); @@ -181,30 +199,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 +214,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]); } } @@ -238,6 +232,8 @@ public function update($id, UpdateDriverRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryDrivers($request); return $this->driverResourceCollection($results); @@ -252,6 +248,11 @@ public function query(Request $request) */ public function find($id) { + // Retrieve carries no Request parameter, so the container's is used — + // otherwise an unsupported `with` on this endpoint would reach Eloquent + // unmapped. + $this->applyPublicExpansions(request(), static::EXPANDABLE); + // find for the driver try { $driver = $this->findDriver($id, ['user', 'vehicle', 'vendor', 'currentJob']); @@ -919,6 +920,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..16e7a0594 100644 --- a/server/src/Http/Controllers/Api/v1/FleetController.php +++ b/server/src/Http/Controllers/Api/v1/FleetController.php @@ -2,61 +2,104 @@ 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\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; 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\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; class FleetController extends Controller { + use ResolvesFleetOpsApiResources; + // Aliased so the fleet-specific override can still reach the shared + // resolution before adding the implicit subfleet nesting on top. + use ResolvesPublicExpansions { + resolvePublicExpansions as protected resolveAllowedExpansions; + } + /** - * Creates a new Fleetbase Fleet resource. + * Relationships eager loaded so the public resource can report each + * assignment as a public id without issuing a query per fleet. * - * @param \Fleetbase\Http\Requests\CreateFleetRequest $request + * Loading them does not expand them: the resource returns the nested object + * only for a relation the caller named in `with`, which is the shape the + * endpoint has always had. + */ + protected const PUBLIC_RELATIONS = ['serviceArea', 'zone', 'vendor', 'parentFleet', 'photo']; + + /** + * Public expansion name => Eloquent relation name. + * + * `subfleets` is the reason this map exists rather than a bare list: the + * public name and the relation differ only in case, and the automatic + * camelCase normalisation upstream turns `subfleets` into `subfleets`. + */ + public const EXPANDABLE = [ + 'service_area' => 'serviceArea', + 'zone' => 'zone', + 'vendor' => 'vendor', + 'parent_fleet' => 'parentFleet', + 'photo' => 'photo', + 'subfleets' => 'subFleets', + 'drivers' => 'drivers', + 'vehicles' => 'vehicles', + ]; + + /** + * Creates a new Fleetbase Fleet resource. * * @return \Fleetbase\Http\Resources\Fleet */ public function create(CreateFleetRequest $request) { - // get request input - $input = $request->only(['name']); + $this->applyPublicExpansions($request, static::EXPANDABLE); + + 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 */ public function update($id, UpdateFleetRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // 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 +108,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)); } /** @@ -90,6 +134,8 @@ public function update($id, UpdateFleetRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryFleets($request); return $this->fleetResourceCollection($results); @@ -102,10 +148,12 @@ public function query(Request $request) */ public function find($id, Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the fleet try { $fleet = $this->findFleet($id); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) { + } catch (ModelNotFoundException $exception) { return $this->jsonResponse( [ 'error' => 'Fleet resource not found.', @@ -115,7 +163,7 @@ public function find($id, Request $request) } // response the fleet resource - return $this->fleetResource($fleet); + return $this->fleetResource($this->withPublicRelations($fleet)); } /** @@ -128,7 +176,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 +192,286 @@ 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) + { + 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); + } + + /** + * Preserve the implicit subfleet nesting the released contract had. + * + * `?with[]=subfleets&with[]=drivers` used to load the subfleets' drivers as + * well as the fleet's own, so the nested collections were part of that + * response. The explicit `?with[]=subfleets.drivers` form is the better + * spelling, but dropping the implicit one would remove data a caller is + * already receiving. + * + * @param array $allowed + * + * @return array + */ + protected function resolvePublicExpansions(Request $request, array $allowed): array + { + $resolved = $this->resolveAllowedExpansions($request, $allowed); + + if (!in_array('subFleets', $resolved, true)) { + return $resolved; + } + + foreach (['drivers', 'vehicles'] as $nested) { + $path = 'subFleets.' . $nested; + + if (in_array($nested, $resolved, true) && !in_array($path, $resolved, true)) { + $resolved[] = $path; + } + } + + return $resolved; + } + + /** + * The explicit public input allowlist for a fleet. + * + * 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 + { + $this->assignMembership(FleetVehicle::class, [ + 'fleet_uuid' => $fleet->uuid, + 'vehicle_uuid' => $vehicle->uuid, + ]); + } + + /** + * Create or restore exactly one membership row for a pivot pair. + * + * Read-then-write is idempotent only against itself. Two requests arriving + * together both see no membership and both insert, which is precisely what + * an importer retrying a timed-out call produces. The composite unique index + * is the actual guarantee; this catches the violation the loser gets and + * finishes the winner's work, so both callers still receive the same + * successful answer. + * + * @param class-string $pivotClass + * @param array $attributes + */ + protected function assignMembership(string $pivotClass, array $attributes): void + { + $membership = $pivotClass::withTrashed()->firstOrNew($attributes); + + if ($membership->trashed()) { + $membership->restore(); + + return; + } + + if ($membership->exists) { + return; + } + + try { + $membership->save(); + } catch (UniqueConstraintViolationException $exception) { + // Only this violation is swallowed. Anything else is a real failure + // and must not be reported as a successful assignment. + $winner = $pivotClass::withTrashed()->where($attributes)->first(); + + if (!$winner) { + throw $exception; + } + + if ($winner->trashed()) { + $winner->restore(); + } + } + } + + protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void { - return Utils::getUuid($table, $where); + FleetVehicle::where([ + 'fleet_uuid' => $fleet->uuid, + 'vehicle_uuid' => $vehicle->uuid, + ])->delete(); + } + + protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void + { + $this->assignMembership(FleetDriver::class, [ + 'fleet_uuid' => $fleet->uuid, + 'driver_uuid' => $driver->uuid, + ]); + } + + 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 +484,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..55d53e237 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -5,6 +5,9 @@ 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\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest; use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; @@ -12,16 +15,45 @@ 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; + use ResolvesPublicExpansions; + + /** + * Relationships eager loaded so the public resource can report each + * assignment as a public id without a query per vehicle. + * + * Loading them does not expand them: the nested object is returned only for + * a relation the caller named in `with`, which is the shape the endpoint has + * always had. + */ + protected const PUBLIC_RELATIONS = ['vendor', 'category', 'warranty', 'driver', 'photo']; + + /** + * Public expansion name => Eloquent relation name. + */ + public const EXPANDABLE = [ + 'driver' => 'driver', + 'vendor' => 'vendor', + 'category' => 'category', + 'warranty' => 'warranty', + 'photo' => 'photo', + 'devices' => 'devices', + ]; + /** * Creates a new Fleetbase Vehicle resource. * @@ -31,8 +63,14 @@ class VehicleController extends Controller */ public function create(CreateVehicleRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // 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 +78,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); @@ -85,6 +115,8 @@ public function create(CreateVehicleRequest $request) */ public function update($id, UpdateVehicleRequest $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the vehicle try { $vehicle = $this->findVehicle($id); @@ -98,19 +130,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); @@ -157,6 +182,8 @@ public function update($id, UpdateVehicleRequest $request) */ public function query(Request $request) { + $this->applyPublicExpansions($request, static::EXPANDABLE); + $results = $this->queryVehicles($request); return $this->vehicleResourceCollection($results); @@ -169,8 +196,17 @@ public function query(Request $request) * * @return \Fleetbase\Http\Resources\VehicleCollection */ - public function find($id) + public function find($id, ?Request $request = null) { + // Falls back to the container's request rather than trusting injection: + // the parameter carries a default, and Laravel's controller dispatcher + // skips resolving a type-hinted dependency that has one. It arrives null, + // the expansions are never mapped, and an unsupported name reaches + // Eloquent — a 500 for a typo, on the one endpoint most likely to be + // handed one. + $request = $request instanceof Request ? $request : request(); + $this->applyPublicExpansions($request, static::EXPANDABLE); + // find for the vehicle try { $vehicle = $this->findVehicle($id); @@ -347,23 +383,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 +482,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,12 +499,28 @@ 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) { - return new VehicleResource($vehicle); + return new VehicleResource($this->withPublicRelations($vehicle)); + } + + /** + * Load the relations the public resource reports as identifiers. + * + * One query per relation instead of one per relation per read, and it makes + * an assignment made moments earlier in the same request readable back + * immediately. + */ + protected function withPublicRelations(Vehicle $vehicle): Vehicle + { + $vehicle->loadMissing(static::PUBLIC_RELATIONS); + + return $vehicle; } protected function vehicleResourceCollection($results) diff --git a/server/src/Http/Filter/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..5aa390e50 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( @@ -43,9 +50,28 @@ function ($query) use ($searchQuery) { }); } + /** + * Match a driver by the identifier the operator's own system uses. + * + * Two callers, two meanings. The Fleet-Ops console types into a search box + * and expects `VEH-10` to find `VEH-100`; an importer asks whether `VEH-10` + * exists and must not be told yes because `VEH-100` does. A partial match + * there produces a duplicate on every rerun. + * + * Core's ordinary fillable-column filtering is already equality — this + * method exists only because the console needs the looser behaviour, so it + * is the console that gets the exception. An unknown request context is + * treated as public: exact is the safe default of the two. + */ public function internalId(?string $internalId) { - $this->builder->searchWhere('internal_id', $internalId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('internal_id', $internalId); + + return; + } + + $this->builder->where('internal_id', '=', $internalId); } public function name(?string $name) @@ -58,14 +84,25 @@ function ($query) use ($name) { ); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicId) { - $this->builder->searchWhere('public_id', $publicId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicId); + + return; + } + + $this->builder->where('public_id', '=', $publicId); } public function facilitator(string $facilitator) { - $this->builder->where('vendor_uuid', $facilitator); + $this->builder->whereIn('vendor_uuid', $this->resolvePublicRelationUuids(Vendor::class, $facilitator)); } public function vehicle(string $vehicle) @@ -76,16 +113,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 +142,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 +181,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..169bdc31d 100644 --- a/server/src/Http/Filter/FleetFilter.php +++ b/server/src/Http/Filter/FleetFilter.php @@ -2,11 +2,19 @@ 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; +use Fleetbase\Support\Http; class FleetFilter extends Filter { + use ResolvesPublicRelationUuids; + public function queryForInternal() { $this->builder->where('company_uuid', $this->session->get('company'))->with(['serviceArea', 'zone']); @@ -17,15 +25,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,49 +48,38 @@ 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)); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicId) { - $this->builder->searchWhere('public_id', $publicId); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicId); + + return; + } + + $this->builder->where('public_id', '=', $publicId); } public function task(?string $task) diff --git a/server/src/Http/Filter/VehicleFilter.php b/server/src/Http/Filter/VehicleFilter.php index bd73bc0a8..9257b7a2e 100644 --- a/server/src/Http/Filter/VehicleFilter.php +++ b/server/src/Http/Filter/VehicleFilter.php @@ -2,6 +2,9 @@ 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; @@ -10,6 +13,8 @@ class VehicleFilter extends Filter { + use ResolvesPublicRelationUuids; + public function queryForInternal() { $this->builder->where('company_uuid', $this->session->get('company')); @@ -30,14 +35,56 @@ 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. + */ + /** + * Match a vehicle by the identifier the operator's own system uses. + * + * Two callers, two meanings. The Fleet-Ops console types into a search box + * and expects `VEH-10` to find `VEH-100`; an importer asks whether `VEH-10` + * exists and must not be told yes because `VEH-100` does. A partial match + * there produces a duplicate on every rerun. + * + * Core's ordinary fillable-column filtering is already equality — this + * method exists only because the console needs the looser behaviour, so it + * is the console that gets the exception. An unknown request context is + * treated as public: exact is the safe default of the two. + */ + public function internalId(?string $internalId) + { + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('internal_id', $internalId); + + return; + } + + $this->builder->where('internal_id', '=', $internalId); + } + public function vin(?string $vin) { $this->builder->searchWhere('vin', $vin); } + /** + * A public id identifies exactly one record, so a public lookup matches it + * exactly. The console keeps the partial search its id column filter box + * has always had. + */ public function publicId(?string $publicIc) { - $this->builder->searchWhere('public_id', $publicIc); + if (Http::isInternalRequest($this->request)) { + $this->builder->searchWhere('public_id', $publicIc); + + return; + } + + $this->builder->where('public_id', '=', $publicIc); } public function plateNumber(?string $plateNumber) @@ -68,10 +115,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 +131,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 +168,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..3bc95d57f 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -2,12 +2,16 @@ namespace Fleetbase\FleetOps\Http\Requests; +use Fleetbase\FleetOps\Http\Requests\Concerns\ScopesPublicRelationRules; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Rules\ResolvablePoint; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; class CreateDriverRequest extends FleetbaseRequest { + use ScopesPublicRelationRules; + /** * Determine if the user is authorized to make this request. */ @@ -24,23 +28,110 @@ 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']), $this->uniqueAmongUsers()], + 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()], + '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'), ]; } + /** + * Uniqueness against every other live user account. + * + * Update used to skip this check entirely, so one driver could be given an + * address another driver already signs in with — and the two would then be + * indistinguishable to every identity lookup, including the password reset + * that matches on it. The driver's own linked user is ignored by uuid, so + * resending an unchanged address or number still succeeds. + * + * No return type: the pest harness substitutes its own + * `Illuminate\Validation\Rule`, which is not an instance of `Rules\Unique`. + */ + protected function uniqueAmongUsers() + { + $rule = Rule::unique('users')->whereNull('deleted_at'); + + $ownUserUuid = $this->linkedUserUuid(); + + return $ownUserUuid ? $rule->ignore($ownUserUuid, 'uuid') : $rule; + } + + /** + * The uuid of the user behind the driver this request is updating. + * + * Null on create, and null when there is no resolvable route parameter — + * which is also what keeps this from querying during rule construction in + * contexts that have no route bound. + */ + protected function linkedUserUuid(): ?string + { + if ($this->isMethod('POST')) { + return null; + } + + $id = $this->route('id'); + + if (!is_string($id) || $id === '') { + return null; + } + + return Driver::withoutGlobalScopes() + ->where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('internal_id', $id); + }) + ->value('user_uuid'); + } + /** * Get custom attributes for validator errors. */ diff --git a/server/src/Http/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..14913dce5 --- /dev/null +++ b/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php @@ -0,0 +1,110 @@ +_id` public identifier, and + * as the nested object under the name the endpoint has always used. + * + * The two are deliberately separate keys. The SDK stores whatever the API returns + * verbatim — `Resource::$attributes = $attributes`, no normalisation — so a + * property that is an object on one call and a string on another silently breaks + * every consumer that dereferences it. Navigator interpolates `driver.user` + * straight into a socket channel name; an object there would subscribe it to + * `user.[object Object]` and simply stop delivering messages. So an existing + * object key stays an object key, always, and the identifier arrives beside it. + */ +trait ResolvesPublicRelationFields +{ + /** + * The relations the caller asked for, as real Eloquent relation names. + * + * The controller has already normalised, mapped and allowlisted `with` / + * `expand` by the time a resource runs, so this is a plain read. + * + * @return array + */ + protected function requestedRelations($request): array + { + $with = $request->input('with'); + + if (is_string($with)) { + $with = explode(',', $with); + } + + if (!is_array($with)) { + return []; + } + + return array_values(array_filter(array_map('strval', $with), 'strlen')); + } + + /** + * The nested object for a relationship. + * + * Internal console requests keep the exact `whenLoaded` behaviour they have + * always had. Public requests get the object only when they asked for it, + * which is also what they got before: these relations were never eager + * loaded on the public endpoints, so the key was absent unless `with` named + * it. Absent stays absent; it never becomes a string. + * + * @param array $with camelCased relations the caller asked for + */ + protected function publicRelationObject(string $relation, array $with, \Closure $resource): mixed + { + if (Http::isInternalRequest()) { + return $this->whenLoaded($relation, $resource); + } + + if (!in_array($relation, $with, true)) { + return new MissingValue(); + } + + $this->loadRelationIfPossible($relation); + + return $this->{$relation} ? $resource() : null; + } + + /** + * The public id behind a relationship, or null when nothing is assigned. + * + * Additive and unconditional: it is present whether or not the object is, + * and it does not change when the object is expanded. + * + * @param string|null $foreignKey the column holding the relation for a belongsTo; + * null for the inverse side, which has no local column + */ + protected function publicIdForRelation(string $relation, ?string $foreignKey): ?string + { + 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 wraps an Eloquent model — webhook payload fixtures and + // compact serializers pass plain objects — so read the already-materialised + // value rather than calling a relation method that is not there. + if (!is_object($resource) || !method_exists($resource, $relation)) { + return data_get($this, $relation . '.public_id'); + } + + return $this->{$relation}()->value('public_id'); + } + + private function loadRelationIfPossible(string $relation): void + { + $resource = $this->resource; + + if (is_object($resource) && method_exists($resource, 'loadMissing') && method_exists($resource, $relation)) { + $this->loadMissing($relation); + } + } +} diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 2e8fc4cda..0da9aeea4 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, @@ -45,25 +46,38 @@ public function toArray($request) 'vehicle_name' => $this->when(Http::isInternalRequest(), $this->vehicle_name), 'vehicle_avatar' => $this->vehicle_avatar, 'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name), - 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()), - 'current_order_reference' => $this->when(Http::isInternalRequest(), $this->currentOrderReference()), + 'assigned_orders_count' => $this->when(Http::isInternalRequest(), fn () => $this->assignedOrdersCount()), + 'current_order_reference' => $this->when(Http::isInternalRequest(), fn () => $this->currentOrderReference()), 'vehicle' => $this->whenLoaded('vehicle', fn () => new VehicleWithoutDriver($this->vehicle)), 'current_job' => $this->whenLoaded('currentJob', fn () => new Order($this->currentJob)), 'current_job_id' => $this->when(Http::isInternalRequest(), data_get($this, 'currentJob.tracking')), '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..dcf121ab1 100644 --- a/server/src/Http/Resources/v1/Fleet.php +++ b/server/src/Http/Resources/v1/Fleet.php @@ -2,12 +2,15 @@ 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; +use Fleetbase\Support\Resolve; class Fleet extends FleetbaseResource { + use ResolvesPublicRelationFields; + /** * Transform the resource into an array. * @@ -17,22 +20,15 @@ class Fleet extends FleetbaseResource */ public function toArray($request) { - if ($request->isArray('with')) { - $with = array_map(function ($relation) { - return Str::camel($relation); - }, $request->array('with')); - - $this->load($with); - - if (in_array('subfleets', $with, true)) { - if (in_array('drivers', $with, true)) { - $this->loadMissing('subFleets.drivers'); - } + // The controller has already mapped these onto real relation names and + // dropped anything outside the public allowlist, so they are safe to + // hand to the loader. `subfleets` used to arrive here spelled exactly + // that and reach `load('subfleets')`, which is not the relation — + // the documented expansion raised instead of resolving. + $with = $this->requestedRelations($request); - if (in_array('vehicles', $with, true)) { - $this->loadMissing('subFleets.vehicles'); - } - } + if ($with !== []) { + $this->loadMissing($with); } return $this->withCustomFields([ @@ -40,16 +36,31 @@ 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), + // Additive public identifiers. Always present, unchanged when the + // object beside them is expanded, and never a substitute for it. + 'service_area_id' => $this->publicIdForRelation('serviceArea', 'service_area_uuid'), + 'zone_id' => $this->publicIdForRelation('zone', 'zone_uuid'), + 'vendor_id' => $this->publicIdForRelation('vendor', 'vendor_uuid'), + 'parent_fleet_id' => $this->publicIdForRelation('parentFleet', 'parent_fleet_uuid'), + 'photo_id' => $this->publicIdForRelation('photo', 'image_uuid'), + // The objects keep the shape they have always had: absent unless the + // relation was loaded, an object when it was, never a string. + 'service_area' => $this->publicRelationObject('serviceArea', $with, fn () => new ServiceArea($this->serviceArea)), + 'zone' => $this->publicRelationObject('zone', $with, fn () => new Zone($this->zone)), + 'vendor' => $this->publicRelationObject('vendor', $with, fn () => new Vendor($this->vendor)), + 'parent_fleet' => $this->publicRelationObject('parentFleet', $with, fn () => new ParentFleet($this->parentFleet)), + 'photo' => $this->publicRelationObject('photo', $with, fn () => Resolve::httpResourceForModel($this->photo)), 'subfleets' => $this->whenLoaded('subFleets', fn () => SubFleet::collection($this->subFleets)), 'drivers' => $this->whenLoaded('drivers', fn () => Driver::collection($this->drivers()->with(Http::isInternalRequest() || $request->has('with.jobs') ? ['jobs'] : [])->get())), 'vehicles' => $this->whenLoaded('vehicles', fn () => Vehicle::collection($this->vehicles)), @@ -68,11 +79,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..ed1b73354 100644 --- a/server/src/Http/Resources/v1/Vehicle.php +++ b/server/src/Http/Resources/v1/Vehicle.php @@ -2,12 +2,16 @@ 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; +use Fleetbase\Support\Resolve; class Vehicle extends FleetbaseResource { + use ResolvesPublicRelationFields; + /** * Transform the resource into an array. * @@ -17,6 +21,12 @@ class Vehicle extends FleetbaseResource */ public function toArray($request) { + $with = $this->requestedRelations($request); + + if ($with !== []) { + $this->loadMissing($with); + } + return $this->withCustomFields([ // Identity 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), @@ -39,10 +49,24 @@ public function toArray($request) 'description' => $this->description, 'driver_name' => $this->when(Http::isInternalRequest(), $this->driver_name), 'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name), - 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()), - 'current_order_reference'=> $this->when(Http::isInternalRequest(), $this->currentOrderReference()), - // Relationships - 'driver' => $this->whenLoaded('driver', fn () => new Driver($this->driver)), + 'assigned_orders_count' => $this->when(Http::isInternalRequest(), fn () => $this->assignedOrdersCount()), + 'current_order_reference'=> $this->when(Http::isInternalRequest(), fn () => $this->currentOrderReference()), + // Relationships. The identifier is additive and always present so a + // write can be read back; the object beside it keeps the shape it + // has always had — absent unless loaded, an object when it is. + 'driver_id' => $this->publicIdForRelation('driver', null), + 'vendor_id' => $this->publicIdForRelation('vendor', 'vendor_uuid'), + 'category_id' => $this->publicIdForRelation('category', 'category_uuid'), + 'warranty_id' => $this->publicIdForRelation('warranty', 'warranty_uuid'), + 'photo_id' => $this->publicIdForRelation('photo', 'photo_uuid'), + 'driver' => $this->publicRelationObject('driver', $with, fn () => new Driver($this->driver)), + 'vendor' => $this->publicRelationObject('vendor', $with, fn () => new Vendor($this->vendor)), + // Resolved through the repository's own resource resolver rather than + // a hardcoded class: Category and File live in core, and Warranty has + // no v1 resource of its own. + 'category' => $this->publicRelationObject('category', $with, fn () => Resolve::httpResourceForModel($this->category)), + 'warranty' => $this->publicRelationObject('warranty', $with, fn () => Resolve::httpResourceForModel($this->warranty)), + 'photo' => $this->publicRelationObject('photo', $with, fn () => Resolve::httpResourceForModel($this->photo)), 'devices' => $this->whenLoaded('devices', fn () => $this->devices), // Vehicle identification 'make' => $this->make, @@ -139,6 +163,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 +306,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..fa5e4dfa7 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,240 @@ 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']); +}); + +test('api driver controller keeps every relationship the base contract returned', function () { + // The SDK stores what the API returns verbatim, and Navigator interpolates + // `driver.user` straight into a socket channel name — an object there would + // subscribe it to `user.[object Object]` and quietly stop delivering + // messages. These endpoints have always loaded user, vehicle, vendor and + // currentJob, so they must keep doing it without a `with` parameter. + $controller = new FleetOpsApiDriverControllerProbe(); + + $controller->create(new CreateDriverRequest([ + 'name' => 'Driver One', + 'vehicle' => 'vehicle_public', + 'vendor' => 'vendor_public', + 'job' => 'order_public', + ])); + + expect($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']); + + $updateController = new FleetOpsApiDriverControllerProbe(); + $updateController->update('driver_public', new UpdateDriverRequest(['name' => 'Renamed'])); + + expect($updateController->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']) + ->and($updateController->findCalls)->toContain(['driver_public', ['user']]); +}); + +test('api driver controller copies timezone through to the linked user account', function () { + // `timezone` is documented and accepted on both create and update, but the + // update only ever copied name, email and phone — so the request validated, + // answered 200, and dropped it. The driver has no timezone column; the + // linked user does. + $create = new FleetOpsApiDriverControllerProbe(); + $create->create(new CreateDriverRequest([ + 'name' => 'Driver One', + 'timezone' => 'Asia/Singapore', + ])); + + $user = new FleetOpsApiDriverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid'], true); + + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); + $driver->userForTest = $user; + $driver->setRelation('user', $user); + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + $update->update('driver_public', new UpdateDriverRequest([ + 'name' => 'Driver One', + 'timezone' => 'Europe/Amsterdam', + ])); + + expect($create->createdUsers[0])->toMatchArray(['timezone' => 'Asia/Singapore']) + ->and($user->updates)->toContain([ + 'name' => 'Driver One', + 'timezone' => 'Europe/Amsterdam', + ]); +}); + +test('api driver controller only expands relationships the public contract allows', function () { + $controller = new FleetOpsApiDriverControllerProbe(); + $request = new CreateDriverRequest(['name' => 'Driver One', 'with' => ['vehicle', 'current_job', 'user', 'company', 'nope']]); + + $controller->create($request); + + // `user` and `company` are published as public-id strings. Expanding either + // would retype a released field, so neither is expandable at all; an unknown + // name is dropped rather than reaching Eloquent, where it would be a 500. + expect($request->input('with'))->toBe(['vehicle', 'currentJob']); +}); diff --git a/server/tests/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..00d7edb67 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]; - return 'service-area-uuid'; + if (in_array($id, $this->unresolvable, true)) { + throw (new ModelNotFoundException())->setModel($modelClass, $id); + } + + return $id . '-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,191 @@ 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) { + $expected = [ + 'json' => ['error' => 'No ' . str_replace('_', ' ', $relation) . ' resource found for the identifier provided.'], + 'status' => 404, + ]; + + $controller = new FleetOpsApiFleetControllerProbe(); + $controller->unresolvable = ['other_company_id']; + + $created = $controller->create(fleetopsCreateFleetRequest([ + 'name' => 'Carpool', + $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. + // Update answers the same way create does, and writes nothing. + expect($created)->toBe($expected) + ->and($updated)->toBe($expected) + ->and($updateController->fleet->updates)->toBe([]); + } +}); + 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 +408,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 +433,77 @@ 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, + ]) + // 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/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..976cb5652 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']); @@ -482,20 +572,32 @@ public function get(string $key): ?string ->and($query->calls)->toContain(['search', 'van']) ->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter']) ->and($query->calls)->toContain(['searchWhere', 'vin', 'vin-123']) - ->and($query->calls)->toContain(['searchWhere', 'public_id', 'vehicle-public']) + // A public id names exactly one record, so a public lookup is exact. + ->and($query->calls)->toContain(['where', ['public_id', '=', 'vehicle-public']]) + // Exact, not partial: an importer asking whether VEH-42 exists must not + // be told yes because VEH-420 does. + ->and($query->calls)->toContain(['where', ['internal_id', '=', 'VEH-42']]) ->and($query->calls)->toContain(['searchWhere', 'plate_number', 'ABC-123']) ->and($query->calls)->toContain(['searchWhere', 'make', 'Mercedes']) ->and($query->calls)->toContain(['searchWhere', 'model', 'Sprinter']) ->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 +606,12 @@ 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']); + // A blank identifier resolves to nothing without reaching the database. + $filter->vendor(''); $filter->publicId('fleet-public'); $filter->task('delivery'); $filter->name('North Fleet'); @@ -515,16 +619,27 @@ 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(['where', ['public_id', '=', 'fleet-public']]) ->and($query->calls)->toContain(['searchWhere', 'task', 'delivery']) ->and($query->calls)->toContain(['searchWhere', 'name', 'North Fleet']) - ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']]); - - 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']]]) + ->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') ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1) ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1); }); @@ -1224,9 +1339,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 +1359,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..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); @@ -110,6 +146,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 +221,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 () { @@ -261,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/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/FleetMembershipMigrationTest.php b/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php new file mode 100644 index 000000000..265380048 --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php @@ -0,0 +1,140 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function getDatabaseName(): string + { + return 'main'; + } + + public function table($table, $as = null) + { + return $this->c->table($table, $as); + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + Schema::clearResolvedInstance('db.schema'); + + $schema = $connection->getSchemaBuilder(); + foreach (['fleet_vehicles' => 'vehicle_uuid', 'fleet_drivers' => 'driver_uuid'] as $table => $subjectColumn) { + $schema->create($table, function ($blueprint) use ($subjectColumn) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('fleet_uuid')->nullable(); + $blueprint->string($subjectColumn)->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + return $connection; +} + +/** + * Reach the migration's cleanup directly. `information_schema` does not exist on + * sqlite, so the index step is exercised separately in FleetMembershipTest. + */ +function fleetopsRunMembershipCleanup(string $table, string $memberColumn): void +{ + $migration = require dirname(__DIR__, 4) . '/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php'; + $reflection = new ReflectionMethod($migration, 'removeDuplicateMemberships'); + $reflection->setAccessible(true); + $reflection->invoke($migration, $table, $memberColumn); +} + +test('duplicate cleanup keeps the active membership and drops the rest', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_vehicles')->insert([ + // A tombstone recorded first, then the membership the fleet actually has. + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => '2026-01-01 00:00:00'], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + // A different pair, and an unrelated fleet, both untouched. + ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-2', 'deleted_at' => null], + ['id' => 5, 'uuid' => 'e', 'fleet_uuid' => 'fleet-2', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ]); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + + $survivors = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + // The live row wins over the tombstone, the lowest id wins among equals, and + // nothing outside the duplicated pair is affected. + expect($survivors)->toBe(['b', 'd', 'e']) + ->and($connection->table('fleet_vehicles')->where('uuid', 'b')->value('deleted_at'))->toBeNull(); +}); + +test('duplicate cleanup keeps one restorable row when every duplicate is soft deleted', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_drivers')->insert([ + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-01-01 00:00:00'], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-02-01 00:00:00'], + ]); + + fleetopsRunMembershipCleanup('fleet_drivers', 'driver_uuid'); + + // Removing both would turn a later re-assignment into a brand new row and + // lose the original membership's history. + expect($connection->table('fleet_drivers')->pluck('uuid')->all())->toBe(['a']) + ->and($connection->table('fleet_drivers')->where('uuid', 'a')->value('deleted_at'))->not->toBeNull(); +}); + +test('duplicate cleanup is idempotent and leaves orphaned rows alone', function () { + $connection = fleetopsMembershipMigrationBoot(); + + $connection->table('fleet_vehicles')->insert([ + ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null], + // Orphaned data rather than a membership: a null side never collides in + // a unique index, so it is not the migration's business. + ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null], + ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null], + ]); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + $afterFirst = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid'); + $afterSecond = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all(); + + expect($afterFirst)->toBe(['a', 'c', 'd']) + ->and($afterSecond)->toBe($afterFirst); +}); diff --git a/server/tests/Feature/Http/Api/FleetMembershipTest.php b/server/tests/Feature/Http/Api/FleetMembershipTest.php new file mode 100644 index 000000000..6af03c764 --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetMembershipTest.php @@ -0,0 +1,333 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + // A dispatcher is required, not optional: without one the uuid hooks never + // fire and — more importantly here — `FleetVehicle::creating()` silently + // registers nothing, so a race test would pass without ever racing. + // Memoised, because a fresh dispatcher drops the hooks of models already + // booted in this process. + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + // The dispatcher brings the lifecycle observers with it — the HTTP cache + // observer calls the `responsecache` binding on every create and delete — + // so satisfy it rather than letting an unrelated facade fail the assertion. + if (!app()->bound('responsecache')) { + app()->instance('responsecache', new class { + public function clear(): void + { + } + + public function forget($uris): void + { + } + + public function __call($method, $arguments) + { + return null; + } + }); + } + + config()->set('activitylog.enabled', false); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + 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 ($table, $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(); + + // The same composite index the migration adds. Soft-deleted rows are + // deliberately inside the key: a removed membership is restored on + // re-assignment rather than replaced, so its key must stay taken. + $blueprint->unique(['fleet_uuid', $subjectColumn], $table . '_pair_unique'); + }); + } + + 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']); +}); + +test('the composite index refuses a duplicate membership pair outright', function () { + $connection = fleetopsMembershipDatabase(); + + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-1', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]); + + // The controller's read-then-write is idempotent only against itself. This + // is the guarantee underneath it: even a direct insert cannot produce a + // second logical membership. + expect(fn () => $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-2', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); + + // A tombstone still occupies the key, which is what forces the restore path. + $connection->table('fleet_vehicles')->where('uuid', 'pivot-1')->update(['deleted_at' => now()]); + + expect(fn () => $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'pivot-3', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); +}); + +test('a competing request that wins the race is adopted rather than reported as an error', function () { + $connection = fleetopsMembershipDatabase(); + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + // The row appears between this request reading an empty table and writing to + // it — the interleaving a retrying importer actually produces. Inserted + // through the query builder so no model event recurses. + $inserted = false; + FleetVehicle::creating(function () use ($connection, &$inserted) { + if ($inserted) { + return; + } + + $inserted = true; + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'winner', + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + 'deleted_at' => now(), + ]); + }); + + try { + fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle); + } finally { + FleetVehicle::flushEventListeners(); + } + + // The loser adopts the winner's row and restores it, so both callers get the + // same successful answer and there is still exactly one membership. + expect($connection->table('fleet_vehicles')->count())->toBe(1) + ->and(FleetVehicle::where([ + 'fleet_uuid' => 'fleet-uuid-1', + 'vehicle_uuid' => 'vehicle-uuid-1', + ])->count())->toBe(1); +}); + +test('a duplicate key that is not this membership is not swallowed', function () { + $connection = fleetopsMembershipDatabase(); + $connection->statement('create unique index fleet_vehicles_uuid_unique on fleet_vehicles (uuid)'); + + $fleet = fleetopsMembershipFleet('fleet-uuid-1', 'fleet_123'); + $vehicle = fleetopsMembershipVehicle('vehicle-uuid-1', 'vehicle_123'); + + // A violation on a different key means something else is wrong. Reporting it + // as a successful assignment would hide a real failure behind a 200. + $collided = false; + FleetVehicle::creating(function ($membership) use ($connection, &$collided) { + if ($collided) { + return; + } + + $collided = true; + + // Pin the uuid and take it first, so the save collides on the uuid index + // rather than on the membership pair. + $membership->uuid = 'collides-on-uuid'; + $connection->table('fleet_vehicles')->insert([ + 'uuid' => 'collides-on-uuid', + 'fleet_uuid' => 'fleet-uuid-9', + 'vehicle_uuid' => 'vehicle-uuid-9', + ]); + }); + + try { + expect(fn () => fleetopsMembershipInvoke('assignVehicleToFleet', $fleet, $vehicle)) + ->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); + } finally { + FleetVehicle::flushEventListeners(); + } +}); diff --git a/server/tests/Feature/Http/Api/FleetPublicContractTest.php b/server/tests/Feature/Http/Api/FleetPublicContractTest.php new file mode 100644 index 000000000..24730da91 --- /dev/null +++ b/server/tests/Feature/Http/Api/FleetPublicContractTest.php @@ -0,0 +1,439 @@ + 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 = []; + + public function __construct(private string $uri) + { + } + + 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 +{ + $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); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + 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(); + }); + } + + // 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) { + $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(); +} + +/** + * Resolve expansions the way a request does, then serialize. + * + * Going through the controller matters: it is what maps the public expansion + * name onto the relation name and drops anything outside the allowlist, and the + * resource is written to trust that mapping. + */ +function fleetopsFleetPayload(Fleet $fleet, bool $internal = false, array $query = []): array +{ + $request = fleetopsFleetResourceRequest($internal, $query); + + $controller = new FleetController(); + $reflection = new ReflectionMethod(FleetController::class, 'applyPublicExpansions'); + $reflection->setAccessible(true); + $reflection->invoke($controller, $request, FleetController::EXPANDABLE); + + return (new FleetResource($fleet))->resolve($request); +} + +test('the public fleet resource reports every writable field and each relationship as an additive id', function () { + $payload = fleetopsFleetPayload(fleetopsFleetResourceFixture()); + + // The identifier is a new key beside the relationship, never a value under + // it. The SDK stores whatever the API returns verbatim, so a property that + // is an object on one call and a string on another breaks every consumer + // that dereferences it. + expect($payload)->toMatchArray([ + 'id' => 'fleet_123', + 'name' => 'Carpool', + 'color' => '#2563EB', + 'task' => 'Employee transport', + 'status' => 'active', + 'service_area_id' => 'service_area_123', + 'zone_id' => 'zone_123', + 'vendor_id' => 'vendor_123', + 'parent_fleet_id' => 'fleet_parent123', + ]) + // Unexpanded, the object keys stay absent exactly as they were before + // this contract gained the identifiers. + ->and($payload)->not->toHaveKeys(['service_area', 'zone', 'vendor', 'parent_fleet', 'photo']) + ->and($payload)->not->toHaveKeys([ + 'uuid', + 'public_id', + 'company_uuid', + 'service_area_uuid', + 'zone_uuid', + 'vendor_uuid', + 'parent_fleet_uuid', + 'image_uuid', + ]); +}); + +test('the public fleet resource reports a root fleet with null relationship ids', function () { + $fleet = fleetopsFleetResourceFixture(['parent_fleet_uuid' => null, 'zone_uuid' => null]); + + $payload = fleetopsFleetPayload($fleet); + + expect($payload)->toHaveKeys(['parent_fleet_id', 'zone_id']) + ->and($payload['parent_fleet_id'])->toBeNull() + ->and($payload['zone_id'])->toBeNull(); +}); + +test('the internal fleet resource keeps its nested relationship shape', function () { + $fleet = fleetopsFleetResourceFixture()->load(['serviceArea', 'zone', 'vendor', 'parentFleet']); + $payload = fleetopsFleetPayload($fleet, 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('expanding a fleet relationship adds the object and leaves the id untouched', function () { + $payload = fleetopsFleetPayload( + fleetopsFleetResourceFixture(), + false, + ['with' => ['service_area', 'parent_fleet']] + ); + + expect($payload['service_area'])->toBeObject() + ->and($payload['parent_fleet'])->toBeObject() + // The identifier is unchanged by expansion, and agrees with the object. + ->and($payload['service_area_id'])->toBe('service_area_123') + ->and($payload['parent_fleet_id'])->toBe('fleet_parent123') + // The expanded object and the identifier agree. Asserted on the parent + // fleet: a ServiceArea serializes its border through GEOS, which the + // harness has no extension for. + ->and($payload['parent_fleet']->resolve()['id'])->toBe($payload['parent_fleet_id']) + // A relation that was not asked for is still absent, not a string. + ->and($payload)->not->toHaveKey('vendor') + ->and($payload['vendor_id'])->toBe('vendor_123'); +}); + +test('fleet expansion accepts every documented input spelling', function () { + $scalar = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area']); + $array = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => ['service_area']]); + $csv = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area,vendor']); + $expand = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['expand' => ['service_area']]); + $camel = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'serviceArea']); + + // ?with=x, ?with[]=x, ?with=x,y and ?expand=x all mean the same thing. + expect($scalar['service_area'])->toBeObject() + ->and($array['service_area'])->toBeObject() + ->and($csv['service_area'])->toBeObject() + ->and($csv['vendor'])->toBeObject() + ->and($expand['service_area'])->toBeObject() + ->and($camel['service_area'])->toBeObject(); +}); + +test('an unsupported fleet expansion is ignored rather than reaching eloquent', function () { + // Core hands `with` straight to load(), so an unknown name would be a 500 + // for what is only a typo. The allowlist drops it and the response is + // otherwise unchanged. + $payload = fleetopsFleetPayload( + fleetopsFleetResourceFixture(), + false, + ['with' => ['not_a_relation', 'company', 'service_area']] + ); + + expect($payload['service_area'])->toBeObject() + ->and($payload)->not->toHaveKeys(['not_a_relation', 'company']) + ->and($payload['id'])->toBe('fleet_123'); +}); + +test('public fleet membership routes are registered with public id parameters', function () { + $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(); + } +}); + +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(); +}); + +test('the fleet resource accepts a scalar with parameter it was handed directly', function () { + // The controller normalises `with` into an array before the resource runs, + // but the resource is also reachable from code that passes the raw scalar + // through — it must read the same list either way. + $request = fleetopsFleetResourceRequest(false, ['with' => 'serviceArea,parentFleet']); + $payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve($request); + + expect($payload['service_area'])->toBeObject() + ->and($payload['parent_fleet'])->toBeObject() + ->and($payload['service_area_id'])->toBe('service_area_123'); +}); diff --git a/server/tests/Feature/Http/Api/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/PublicIdentifierFilterTest.php b/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php new file mode 100644 index 000000000..6bb3248e3 --- /dev/null +++ b/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php @@ -0,0 +1,293 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + // `searchWhere` is a Builder macro registered by core's service provider, + // which does not boot here. Mirrors the real one: case-insensitive LIKE with + // dots and commas treated as wildcards. + if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('searchWhere')) { + Illuminate\Database\Eloquent\Builder::macro('searchWhere', function ($column, $search, $strict = false) { + if ($strict === true) { + return $this->where($column, $search); + } + + $needle = '%' . str_replace(['.', ','], '%', (string) $search) . '%'; + + return $this->where(DB::raw('lower(' . $column . ')'), 'like', strtolower($needle)); + }); + } + + $schema = $connection->getSchemaBuilder(); + foreach (['vehicles', 'drivers', 'users'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $rows = ['VEH-10', 'VEH-100', 'VEH-101']; + $index = 0; + foreach ($rows as $internalId) { + $index++; + $connection->table('vehicles')->insert([ + 'uuid' => 'vehicle-uuid-' . $index, + 'public_id' => 'vehicle_id' . $index, + 'internal_id' => $internalId, + 'company_uuid' => 'company-uuid', + ]); + $connection->table('users')->insert(['uuid' => 'user-uuid-' . $index, 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert([ + 'uuid' => 'driver-uuid-' . $index, + 'public_id' => 'driver_id' . $index, + 'internal_id' => str_replace('VEH', 'DRV', $internalId), + 'company_uuid' => 'company-uuid', + 'user_uuid' => 'user-uuid-' . $index, + ]); + } + + // Another tenant holding the very same internal id. + $connection->table('vehicles')->insert([ + 'uuid' => 'vehicle-other-company', + 'public_id' => 'vehicle_other01', + 'internal_id' => 'VEH-10', + 'company_uuid' => 'another-company', + ]); + $connection->table('users')->insert(['uuid' => 'user-other', 'company_uuid' => 'another-company']); + $connection->table('drivers')->insert([ + 'uuid' => 'driver-other-company', + 'public_id' => 'driver_other01', + 'internal_id' => 'DRV-10', + 'company_uuid' => 'another-company', + 'user_uuid' => 'user-other', + ]); + + session(['company' => 'company-uuid']); + + return $connection; +} + +class FleetOpsIdentifierFilterRoute +{ + public array $action = []; + + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } +} + +function fleetopsIdentifierFilterRequest(string $uri, array $query): Request +{ + $request = Request::create('/' . $uri, 'GET', $query); + $session = app('session.store'); + $session->put('company', 'company-uuid'); + $request->setLaravelSession($session); + $request->setRouteResolver(fn () => new FleetOpsIdentifierFilterRoute($uri)); + + return $request; +} + +/** + * @return array + */ +function fleetopsFilteredInternalIds(string $filterClass, string $modelClass, string $uri, array $query): array +{ + $filter = new $filterClass(fleetopsIdentifierFilterRequest($uri, $query)); + + return $filter->apply($modelClass::query())->pluck('internal_id')->sort()->values()->all(); +} + +test('a public vehicle lookup by internal id matches exactly and stays inside the tenant', function () { + fleetopsIdentifierFilterBoot(); + + $matched = fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['internal_id' => 'VEH-10']); + + // VEH-100 and VEH-101 are different vehicles, and the other tenant's VEH-10 + // is a different vehicle again. + expect($matched)->toBe(['VEH-10']); +}); + +test('the console keeps partial internal id search on vehicles', function () { + fleetopsIdentifierFilterBoot(); + + $matched = fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'int/v1/fleet-ops/vehicles', ['internal_id' => 'VEH-10']); + + expect($matched)->toBe(['VEH-10', 'VEH-100', 'VEH-101']); +}); + +test('a public driver lookup by internal id matches exactly and stays inside the tenant', function () { + fleetopsIdentifierFilterBoot(); + + expect(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'v1/drivers', ['internal_id' => 'DRV-10'])) + ->toBe(['DRV-10']); +}); + +test('the console keeps partial internal id search on drivers', function () { + fleetopsIdentifierFilterBoot(); + + expect(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'int/v1/fleet-ops/drivers', ['internal_id' => 'DRV-10'])) + ->toBe(['DRV-10', 'DRV-100', 'DRV-101']); +}); + +test('a public lookup by public id is exact for vehicles and drivers', function () { + fleetopsIdentifierFilterBoot(); + + // vehicle_id1 is a prefix of nothing here, so the partial and exact forms + // agree — the assertion that matters is that a public request produces an + // equality comparison at all, which the tenant row below proves. + expect(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['public_id' => 'vehicle_id1'])) + ->toBe(['VEH-10']) + ->and(fleetopsFilteredInternalIds(DriverFilter::class, Driver::class, 'v1/drivers', ['public_id' => 'driver_id1'])) + ->toBe(['DRV-10']) + // Another tenant's record is never reachable, by either identifier. + ->and(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['public_id' => 'vehicle_other01'])) + ->toBe([]); +}); + +test('vin and plate number keep the search behaviour they already had', function () { + $connection = fleetopsIdentifierFilterBoot(); + $connection->getSchemaBuilder()->table('vehicles', function ($blueprint) { + $blueprint->string('vin')->nullable(); + $blueprint->string('plate_number')->nullable(); + }); + $connection->table('vehicles')->where('uuid', 'vehicle-uuid-1')->update(['vin' => 'VIN1234567890', 'plate_number' => 'SG-1000']); + $connection->table('vehicles')->where('uuid', 'vehicle-uuid-2')->update(['vin' => 'VIN1234567891', 'plate_number' => 'SG-10001']); + + // Untouched by this change: both still match on a prefix. + expect(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['vin' => 'VIN123456789'])) + ->toBe(['VEH-10', 'VEH-100']) + ->and(fleetopsFilteredInternalIds(VehicleFilter::class, Vehicle::class, 'v1/vehicles', ['plate_number' => 'SG-1000'])) + ->toBe(['VEH-10', 'VEH-100']); +}); + +test('the console keeps partial public id search on vehicles drivers and fleets', function () { + $connection = fleetopsIdentifierFilterBoot(); + $connection->getSchemaBuilder()->create('fleets', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('fleets')->insert([ + ['uuid' => 'fleet-1', 'public_id' => 'fleet_id1', 'company_uuid' => 'company-uuid', 'name' => 'One'], + ['uuid' => 'fleet-2', 'public_id' => 'fleet_id12', 'company_uuid' => 'company-uuid', 'name' => 'Two'], + ]); + + // The console's id column filter is a search box; the public API's is a + // lookup. Both behaviours are kept, chosen by the resolved route. + $vehicles = (new VehicleFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/vehicles', ['public_id' => 'vehicle_id1']))) + ->apply(Vehicle::query())->pluck('public_id')->sort()->values()->all(); + + $drivers = (new DriverFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/drivers', ['public_id' => 'driver_id1']))) + ->apply(Driver::query())->pluck('public_id')->sort()->values()->all(); + + $fleets = (new Fleetbase\FleetOps\Http\Filter\FleetFilter(fleetopsIdentifierFilterRequest('int/v1/fleet-ops/fleets', ['public_id' => 'fleet_id1']))) + ->apply(Fleetbase\FleetOps\Models\Fleet::query())->pluck('public_id')->sort()->values()->all(); + + expect($vehicles)->toBe(['vehicle_id1']) + ->and($drivers)->toBe(['driver_id1']) + // fleet_id1 is a prefix of fleet_id12, so the console returns both. + ->and($fleets)->toBe(['fleet_id1', 'fleet_id12']); + + // The public API returns only the exact match. + $publicFleets = (new Fleetbase\FleetOps\Http\Filter\FleetFilter(fleetopsIdentifierFilterRequest('v1/fleets', ['public_id' => 'fleet_id1']))) + ->apply(Fleetbase\FleetOps\Models\Fleet::query())->pluck('public_id')->all(); + + expect($publicFleets)->toBe(['fleet_id1']); +}); + +test('the driver update request scopes its uniqueness lookup to the caller company', function () { + fleetopsIdentifierFilterBoot(); + + $request = Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest::create('/v1/drivers/driver_id1', 'PUT', ['email' => 'a@example.test']); + $request->setRouteResolver(fn () => new class { + public function parameter($name, $default = null) + { + return $name === 'id' ? 'driver_id1' : $default; + } + + public function uri(): string + { + return 'v1/drivers/{id}'; + } + }); + app()->instance('request', $request); + + $reflection = new ReflectionMethod($request, 'linkedUserUuid'); + $reflection->setAccessible(true); + + // Ignoring the driver's own user by uuid is what lets an unchanged address + // be resent without tripping the uniqueness rule. + expect($reflection->invoke($request))->toBe('user-uuid-1'); + + // Another tenant's driver is not reachable, so its user is never ignored. + $foreign = Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest::create('/v1/drivers/driver_other01', 'PUT', []); + $foreign->setRouteResolver(fn () => new class { + public function parameter($name, $default = null) + { + return $name === 'id' ? 'driver_other01' : $default; + } + + public function uri(): string + { + return 'v1/drivers/{id}'; + } + }); + + expect($reflection->invoke($foreign))->toBeNull(); +}); diff --git a/server/tests/Feature/Http/Api/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/VehiclePublicContractTest.php b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php new file mode 100644 index 000000000..15cf72cdc --- /dev/null +++ b/server/tests/Feature/Http/Api/VehiclePublicContractTest.php @@ -0,0 +1,454 @@ +only()` does not catch it. + */ +if (!function_exists('Fleetbase\Observers\event')) { + eval('namespace Fleetbase\Observers; function event($event = null) { return $event; }'); +} + +if (!function_exists('Fleetbase\Support\auth')) { + eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }'); +} + +if (!function_exists('Fleetbase\Support\session')) { + eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }'); +} + +if (!Request::hasMacro('getController')) { + Request::macro('getController', fn () => new VehicleController()); +} + +if (!Request::hasMacro('or')) { + Request::macro('or', function (array $params = [], $default = null) { + foreach ($params as $param) { + if ($this->has($param)) { + return $this->input($param); + } + } + + return $default; + }); +} + +class FleetOpsVehicleContractRoute +{ + public array $action = []; + + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } + + public function getAction($key = null): string + { + return VehicleController::class . '@query'; + } + + public function getActionMethod(): string + { + return 'query'; + } + + public function getName(): string + { + return 'api.v1.vehicles.query'; + } + + public function parameters(): array + { + return []; + } +} + +function fleetopsVehicleContractBoot(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + + // The spatial grammar wraps a point in ST_GeomFromText even on sqlite, so the + // function has to exist for a vehicle carrying coordinates to save at all. + foreach (['ST_PointFromText', 'ST_GeomFromText'] as $fn) { + $pdo->sqliteCreateFunction($fn, fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + } + + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + // Without a dispatcher the uuid and public_id creating hooks never fire and + // every record comes back with a null id — which reads as a serialization + // bug rather than a missing fixture. Memoised: a fresh dispatcher would drop + // the hooks of models booted earlier in the process. + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + if (!app()->bound('responsecache')) { + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + } + + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); + } + + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + $vehicle = new Vehicle(); + + $schema->create('vehicles', function ($blueprint) use ($vehicle) { + $blueprint->increments('id'); + foreach (array_unique(array_merge($vehicle->getFillable(), ['uuid', 'public_id', '_key', 'telematic_uuid'])) as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + foreach (['vendors', 'categories', 'warranties', 'files', 'drivers', 'users', 'directives', 'orders', 'custom_field_values', 'custom_fields', 'places', 'contacts', 'vendor_personnels', 'integrated_vendors'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach ([ + 'uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', + 'name', '_key', 'for', 'owner_uuid', 'owner_type', 'subject_uuid', 'subject_type', + 'permission_uuid', 'key', 'rules', 'disk', 'path', 'bucket', 'type', 'original_filename', + 'provider', 'policy_number', 'status', 'vehicle_assigned_uuid', 'driver_assigned_uuid', 'tracking', + 'custom_field_uuid', 'value', 'value_type', 'label', 'contact_uuid', 'vendor_uuid', 'place_uuid', + ] as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-uuid']); + $connection->table('vendors')->insert(['uuid' => 'vendor-uuid', 'public_id' => 'vendor_contract1', 'company_uuid' => 'company-uuid', 'name' => 'Acme']); + $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_contract1', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']); + + return $connection; +} + +function fleetopsVehicleContractRequest(string $class, string $method, array $payload, string $uri = 'v1/vehicles') +{ + $request = $class::create('/' . $uri, $method, $payload); + $store = app('session.store'); + $store->put('company', 'company-uuid'); + $request->setLaravelSession($store); + $request->setRouteResolver(fn () => new FleetOpsVehicleContractRoute($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * Every safe writable field, with a value whose type exercises its cast. + * + * @return array + */ +function fleetopsVehicleContractPayload(): array +{ + return [ + 'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van', + 'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024, + 'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1', + 'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001', + 'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001', + 'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12, + 'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l', + 'online' => true, 'status' => 'available', + 'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb', + 'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5, + 'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06, + 'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400, + 'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70, + 'financing_status' => 'financed', 'loan_number_of_payments' => 48, + 'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD', + 'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000, + 'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5, + 'current_value' => 38000, 'acquisition_cost' => 52000, + 'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02', + 'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false, + 'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue', + 'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline', + 'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168, + 'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750, + 'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4', + 'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool', + 'meta' => ['depot' => 'north'], + 'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25, + 'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40, + 'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true, + 'altitude' => 15, 'heading' => 180, 'speed' => 42, + ]; +} + +/** + * Compare a serialized response against what was sent, allowing for the casts + * the model applies on the way through. + * + * @return array fields whose round trip did not hold + */ +function fleetopsVehicleContractMismatches(array $sent, array $payload): array +{ + $mismatches = []; + + foreach ($sent as $field => $expected) { + if (!array_key_exists($field, $payload)) { + $mismatches[] = $field . ' (absent from the response)'; + continue; + } + + $actual = $payload[$field]; + + if (is_array($expected)) { + if (json_encode($actual) !== json_encode($expected)) { + $mismatches[] = $field; + } + continue; + } + + if (is_bool($expected)) { + if ((bool) $actual !== $expected) { + $mismatches[] = $field; + } + continue; + } + + if (is_numeric($expected) && is_numeric($actual)) { + // Decimal casts return strings; the value is what matters, not the shape. + if (abs(((float) $actual) - ((float) $expected)) > 0.0001) { + $mismatches[] = $field; + } + continue; + } + + // Dates and datetimes are the documented canonical transformation: a + // `2026-01-02` goes in and an ISO-8601 instant comes back out. + if ($actual instanceof DateTimeInterface) { + if ($actual->format('Y-m-d') !== substr((string) $expected, 0, 10)) { + $mismatches[] = $field . ' (' . $actual->format('c') . ' != ' . $expected . ')'; + } + continue; + } + + $actualString = is_scalar($actual) ? (string) $actual : trim((string) json_encode($actual), '"'); + + // Times come back with seconds appended. + if ($actualString !== '' && str_starts_with($actualString, (string) $expected)) { + continue; + } + + if ($actualString !== (string) $expected) { + $mismatches[] = $field . ' (' . $actualString . ' != ' . $expected . ')'; + } + } + + return $mismatches; +} + +test('every safe writable vehicle field survives create retrieve update and query', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $sent = fleetopsVehicleContractPayload(); + + // ---- create ---- + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', $sent + ['vendor' => 'vendor_contract1']); + $created = $controller->create($createRequest)->resolve($createRequest); + + expect(fleetopsVehicleContractMismatches($sent, $created))->toBe([]) + ->and($created['id'])->toMatch('/^vehicle_/') + ->and($created['vendor_id'])->toBe('vendor_contract1'); + + $publicId = $created['id']; + + // ---- retrieve ---- + $findRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []); + $found = $controller->find($publicId, $findRequest)->resolve($findRequest); + + expect(fleetopsVehicleContractMismatches($sent, $found))->toBe([]); + + // ---- update: every field again, with different values ---- + $updated = [ + 'name' => 'Depot Van 2', 'odometer' => 41250, 'color' => 'Silver', + 'seating_capacity' => 5, 'weight' => 2200.25, 'purchased_at' => '2026-02-03', + 'loan_first_payment' => '2026-03-15', 'loan_amount' => 31000, + 'insurance_value' => 40000, 'depreciation_rate' => 11.5, 'current_value' => 37000, + 'acquisition_cost' => 51000, 'specs' => ['doors' => 5], 'details' => ['liftgate' => false], + 'notes' => 'Regional pool', 'meta' => ['depot' => 'south'], 'skills' => ['refrigerated'], + 'max_tasks' => 30, 'time_window_start' => '07:30', 'time_window_end' => '17:30', + 'return_to_depot' => false, 'fuel_card_number' => 'FC-9002', 'online' => false, + ]; + $updateRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', $updated); + $updateResponse = $controller->update($publicId, $updateRequest)->resolve($updateRequest); + + expect(fleetopsVehicleContractMismatches($updated, $updateResponse))->toBe([]) + // A partial update leaves everything it did not name alone. + ->and($updateResponse['vin'])->toBe('1FTBW3XG8NKA00001') + ->and($updateResponse['make'])->toBe('Ford') + ->and($updateResponse['vendor_id'])->toBe('vendor_contract1'); + + // ---- query ---- + $queryRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []); + $collection = $controller->query($queryRequest)->resolve($queryRequest); + $queried = $collection[0]; + + expect(fleetopsVehicleContractMismatches($updated, $queried))->toBe([]) + // The four operations agree on the same business-field contract. + ->and(array_diff(array_keys($created), array_keys($queried)))->toBe([]); +}); + +test('vehicle status normalisation and coordinate canonicalisation are the documented transformations', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'status' => 'active', + 'latitude' => 40.7484, + 'longitude' => -73.9857, + ]); + + $payload = $controller->create($request)->resolve($request); + + // `active` is accepted and stored as `available`; the coordinates arrive as + // two scalars and come back canonically inside `location`. + expect($payload['status'])->toBe('available') + ->and($payload)->toHaveKey('location') + ->and($payload['location'])->not->toBeNull(); +}); + +test('vehicle relationships are additive: the id is new, the object keeps its shape', function () { + $connection = fleetopsVehicleContractBoot(); + $controller = new VehicleController(); + + $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'vendor' => 'vendor_contract1', + 'driver' => 'driver_contract1', + ]); + $created = $controller->create($request)->resolve($request); + + expect($created['vendor_id'])->toBe('vendor_contract1') + ->and($created['driver_id'])->toBe('driver_contract1') + ->and($created['category_id'])->toBeNull() + ->and($created['warranty_id'])->toBeNull() + ->and($created['photo_id'])->toBeNull() + // Unexpanded, the object keys stay absent — never a string under them. + ->and($created)->not->toHaveKeys(['vendor', 'driver', 'category', 'warranty', 'photo']) + ->and($created)->not->toHaveKeys(['uuid', 'public_id', 'company_uuid', 'vendor_uuid', 'photo_uuid']); + + // Expansion adds the object and leaves the identifier untouched. + $expandRequest = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor']]); + $expanded = $controller->find($created['id'], $expandRequest)->resolve($expandRequest); + + expect($expanded['vendor'])->toBeObject() + ->and($expanded['vendor_id'])->toBe('vendor_contract1') + ->and($expanded['vendor']->resolve()['id'])->toBe($expanded['vendor_id']) + ->and($expanded)->not->toHaveKey('driver'); + + // Clearing a relationship reports a null identifier. + $clearRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', ['vendor' => null]); + $cleared = $controller->update($created['id'], $clearRequest)->resolve($clearRequest); + + expect($cleared['vendor_id'])->toBeNull() + ->and($connection->table('vehicles')->count())->toBe(1); +}); + +test('the internal vehicle resource keeps its counters and its nested driver', function () { + $connection = fleetopsVehicleContractBoot(); + $controller = new VehicleController(); + + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [ + 'make' => 'Ford', + 'driver' => 'driver_contract1', + ]); + $created = $controller->create($createRequest)->resolve($createRequest); + + $vehicleUuid = $connection->table('vehicles')->where('public_id', $created['id'])->value('uuid'); + $connection->table('orders')->insert([ + ['uuid' => 'order-1', 'public_id' => 'order_1', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-1'], + ['uuid' => 'order-2', 'public_id' => 'order_2', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-2'], + ]); + + $internalRequest = fleetopsVehicleContractRequest(Request::class, 'GET', [], 'int/v1/fleet-ops/vehicles'); + $vehicle = Vehicle::where('public_id', $created['id'])->firstOrFail(); + $payload = (new Fleetbase\FleetOps\Http\Resources\v1\Vehicle($vehicle))->resolve($internalRequest); + + // The console reads these; the public contract does not, and no longer pays + // for them — `when()` evaluates a plain value argument eagerly, so both + // counters used to run a query on every public read and discard it. + expect($payload)->toHaveKeys(['uuid', 'public_id', 'assigned_orders_count', 'current_order_reference']) + ->and($payload['assigned_orders_count'])->toBe(2) + ->and($payload['uuid'])->toBe($vehicleUuid) + // Internal keeps the whenLoaded object shape it has always had. + ->and($payload['driver'])->toBeObject(); +}); + +test('an unsupported expansion on retrieve cannot reach eloquent', function () { + fleetopsVehicleContractBoot(); + + $controller = new VehicleController(); + $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', ['make' => 'Ford']); + $created = $controller->create($createRequest)->resolve($createRequest); + + // Retrieve is the endpoint a hand-written client is most likely to hand a + // stale relation name. The request parameter carries a default, and + // Laravel's dispatcher skips injecting a type-hinted dependency that has + // one — so the controller reads the container's request instead. Without + // that, `not_a_relation` reached load() and answered 500. + $request = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor', 'not_a_relation']]); + $payload = $controller->find($created['id'])->resolve($request); + + expect($payload['id'])->toBe($created['id']) + ->and($payload)->not->toHaveKey('not_a_relation') + ->and($request->input('with'))->toBe(['vendor']); +}); 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/FleetHierarchyResourceTest.php b/server/tests/FleetHierarchyResourceTest.php index 311228952..b95ef7d4d 100644 --- a/server/tests/FleetHierarchyResourceTest.php +++ b/server/tests/FleetHierarchyResourceTest.php @@ -1,12 +1,60 @@ setAccessible(true); + + return $reflection->invoke($controller, new Request($query), FleetController::EXPANDABLE); +} + +test('fleet subfleet expansion resolves to the relation name eloquent actually has', function () { + expect(fleetopsHierarchyExpansions(['with' => 'subfleets']))->toBe(['subFleets']) + ->and(fleetopsHierarchyExpansions(['with' => ['subfleets']]))->toBe(['subFleets']) + // The camelCase spelling names the same relation. + ->and(fleetopsHierarchyExpansions(['with' => 'subFleets']))->toBe(['subFleets']); +}); + test('fleet resource expands subfleet drivers and vehicles for hierarchy payloads', function () { - $resource = file_get_contents(__DIR__ . '/../src/Http/Resources/v1/Fleet.php'); - - expect($resource) - ->toContain("if (in_array('subfleets', \$with, true))") - ->toContain("if (in_array('drivers', \$with, true))") - ->toContain("\$this->loadMissing('subFleets.drivers')") - ->toContain("if (in_array('vehicles', \$with, true))") - ->toContain("\$this->loadMissing('subFleets.vehicles')"); + expect(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers']))->toBe(['subFleets.drivers']) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.vehicles']))->toBe(['subFleets.vehicles']) + ->and(fleetopsHierarchyExpansions(['with' => ['subfleets.drivers', 'subfleets.vehicles']])) + ->toBe(['subFleets.drivers', 'subFleets.vehicles']); +}); + +test('fleet expansion refuses a nested path that would recurse or reach an unlisted relation', function () { + // Nothing under a subfleet re-opens the tree, so an expansion cannot be made + // to walk the hierarchy indefinitely. + expect(fleetopsHierarchyExpansions(['with' => 'subfleets.subfleets']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers.user']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => 'company']))->toBe([]); +}); + +test('fleet expansion input normalisation handles every malformed shape without raising', function () { + // Postman and generated clients produce all of these. None may reach + // Eloquent, and none may raise: the parameter is a convenience, not a + // reason to fail an otherwise valid request. + expect(fleetopsHierarchyExpansions(['with' => ['vendor', ['nested', 'array']]]))->toBe(['vendor']) + ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => '.drivers']))->toBe([]) + ->and(fleetopsHierarchyExpansions(['with' => ' vendor , , zone ']))->toBe(['vendor', 'zone']) + ->and(fleetopsHierarchyExpansions(['with' => 'vendor,vendor']))->toBe(['vendor']) + ->and(fleetopsHierarchyExpansions(['with' => '']))->toBe([]) + ->and(fleetopsHierarchyExpansions([]))->toBe([]) + // `expand` is the documented alias and takes over when `with` is empty. + ->and(fleetopsHierarchyExpansions(['with' => '', 'expand' => 'vendor']))->toBe(['vendor']); }); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 361461ffa..075f0efdb 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,30 @@ 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'], + ]) + // 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([]); @@ -838,6 +910,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 +929,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']) diff --git a/server/tests/Unit/Http/Resources/FleetResourceTest.php b/server/tests/Unit/Http/Resources/FleetResourceTest.php index 1f48b3970..91a4c7aba 100644 --- a/server/tests/Unit/Http/Resources/FleetResourceTest.php +++ b/server/tests/Unit/Http/Resources/FleetResourceTest.php @@ -1,5 +1,6 @@ setAccessible(true); + $reflection->invoke($controller, $request, FleetController::EXPANDABLE); + + return (new FleetResource($fleet))->resolve($request); +} + test('fleet resource preloads requested subfleet driver and vehicle relations', function () { fleetopsFleetResourceBoot(); - $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); - $request = Request::create('/v1/fleets/fleet_parentone', 'GET', ['with' => ['subfleets', 'drivers', 'vehicles']]); - - $resolved = (new FleetResource($fleet))->resolve($request); + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + $resolved = fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets', 'drivers', 'vehicles']]); + // `subFleets` is the relation Eloquent actually has. The public name differs + // from it only in case, and PHP method calls are case-insensitive, so + // `load('subfleets')` used to succeed and store a *second* copy under the + // mis-cased key — which `whenLoaded('subFleets')` then could not see. expect($resolved['name'])->toBe('Parent Fleet') - ->and($fleet->relationLoaded('subfleets'))->toBeTrue() - ->and($fleet->relationLoaded('subFleets'))->toBeTrue(); + ->and($fleet->relationLoaded('subFleets'))->toBeTrue() + ->and($resolved)->toHaveKey('subfleets'); $subFleet = $fleet->getRelation('subFleets')->first(); + + // Asking for subfleets alongside drivers and vehicles still nests them, as + // the released contract did. expect($subFleet)->not->toBeNull() ->and($subFleet->relationLoaded('drivers'))->toBeTrue() ->and($subFleet->relationLoaded('vehicles'))->toBeTrue() ->and($subFleet->drivers)->toHaveCount(1) ->and($subFleet->vehicles)->toHaveCount(1); }); + +test('fleet resource resolves subfleets on its own, which the mis-cased load never did', function () { + fleetopsFleetResourceBoot(); + + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + $resolved = fleetopsFleetResourcePayload($fleet, ['with' => 'subfleets']); + + // Before the mapping, this returned no `subfleets` key at all: the load + // landed under `subfleets` and the resource looked for `subFleets`. + expect($resolved)->toHaveKey('subfleets') + ->and($fleet->relationLoaded('subFleets'))->toBeTrue(); +}); + +test('fleet resource accepts the explicit nested expansion spelling', function () { + fleetopsFleetResourceBoot(); + + $fleet = Fleet::where('uuid', 'fleet-parent-1')->first(); + fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets.drivers']]); + + $subFleet = $fleet->getRelation('subFleets')->first(); + + expect($subFleet->relationLoaded('drivers'))->toBeTrue() + ->and($subFleet->relationLoaded('vehicles'))->toBeFalse(); +});