diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index cbdfe270f..3d443ebb7 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -6,7 +6,7 @@ on: tags: - 'v*' pull_request: - branches: [ main, 'dev-v*' ] + branches: [ main, 'release/v*', 'dev-v*' ] env: NODE_VERSION: 22.x diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 702cd6437..24e38fe88 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -18,7 +18,7 @@ on: push: branches: [main] pull_request: - branches: [main, 'dev-v*'] + branches: [main, 'release/v*', 'dev-v*'] workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index ffba178e9..84eb44839 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -6,7 +6,7 @@ on: tags: - 'v*' pull_request: - branches: [ main, 'dev-v*' ] + branches: [ main, 'release/v*', 'dev-v*' ] jobs: build: diff --git a/RELEASE.md b/RELEASE.md index 8e0db0602..ea0acfc74 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,23 +1,38 @@ -> v0.6.61 ~ "A grid view for drivers, a sequenceable order flow, and two silent API no-ops closed" +> v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold" --- ## Highlights -Two API defects in this release were silent: both answered `200` and neither did what the caller asked. A vehicle odometer update was discarded, and a driver-scoped list came back unscoped. Alongside them, the order config flow now publishes enough of its shape to be sequenced, and Drivers Management gains a card layout. +The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number. --- ## Features -- **Drivers Management has a card view.** A layout toggle switches the index between the table and a card grid, and the choice is remembered across visits. -- **The order config flow publishes its graph.** `activities`, `sequence` and `logic` now ride along with each activity, so a consumer can order the flow and offer a next step instead of rendering an unordered set. Transitions are normalised to a list of codes regardless of which of the two stored shapes a flow was authored in. +- **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants. +- **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet. +- **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs. +- **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied. +- **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged. --- ## Bug Fixes -- **`PUT /v1/vehicles/{id}` silently discarded the odometer.** The field is fillable on the model but was missing from the controller's input projection, so a driver app recording mileage received a `200` and a correct-looking body while the reading was dropped. `odometer` and `odometer_unit` are now accepted and validated. -- **Issue and fuel-report lists scoped by `driver_uuid` came back scoped by nothing.** The base filter silently ignores a query parameter it cannot match to a method, so the filter was dropped and the response was narrowed only by company — every driver's records, with no sign the request had been narrowed at all. `driver_uuid`, `driver_assigned` and `vehicle_uuid` are now recognised on both filters. -- **Leaflet marker icons are served from the leaflet package** rather than resolving to a broken URL. +- **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API. +- **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write. +- **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`. +- **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it. +- **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered. +- **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists. +- **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data. +- **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly. +- **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent. --- ## Testing -- Coverage held at 100% across the vehicle input projection, both driver-scoped filters, and the order config flow projection. +- Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes. +- New database-backed coverage of the fleet membership pivots: idempotent assignment, restore of a soft-deleted membership, repeated removal as a no-op, and preservation of the driver's vehicle and of unrelated fleet memberships. +- Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not. + +--- +## Continuous Integration +- The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all. --- ## Need help? diff --git a/addon/services/map-settings.js b/addon/services/map-settings.js index fae705bbd..9c5022c48 100644 --- a/addon/services/map-settings.js +++ b/addon/services/map-settings.js @@ -18,7 +18,10 @@ export default class MapSettingsService extends Service { @service fetch; @tracked settings = { ...DEFAULT_SETTINGS }; @tracked isLoaded = false; - @tracked loadPromise = null; + // Internal request deduplication state is not rendered. Keeping this tracked + // makes load() read and then dirty the same tag when called during component + // construction, which Ember correctly rejects as a render-time mutation. + loadPromise = null; get mapProvider() { return this.settings.mapProvider ?? 'leaflet'; diff --git a/composer.json b/composer.json index 3d31d454c..6cceca1b9 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.61", + "version": "0.6.62", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index d9c3ec0f3..f2834e929 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.61", + "version": "0.6.62", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 1b2a6b1c4..09d2a5345 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.61", + "version": "0.6.62", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" 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/EntityController.php b/server/src/Http/Controllers/Api/v1/EntityController.php index 0a3d166e6..bc8203920 100644 --- a/server/src/Http/Controllers/Api/v1/EntityController.php +++ b/server/src/Http/Controllers/Api/v1/EntityController.php @@ -271,7 +271,7 @@ protected function singularize(?string $value): ?string return $value ? Utils::singularize($value) : null; } - protected function findPayloadByPublicId(string $publicId): ?Payload + protected function findPayloadByPublicId(?string $publicId): ?Payload { return Payload::where('public_id', $publicId)->first(); } diff --git a/server/src/Http/Controllers/Api/v1/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/FuelReportController.php b/server/src/Http/Controllers/Api/v1/FuelReportController.php index 3d316cf15..91b31f8e1 100644 --- a/server/src/Http/Controllers/Api/v1/FuelReportController.php +++ b/server/src/Http/Controllers/Api/v1/FuelReportController.php @@ -157,7 +157,7 @@ public function delete($id) return $this->deletedFuelReportResource($fuelReport); } - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { return Driver::findRecordOrFail($id); } diff --git a/server/src/Http/Controllers/Api/v1/IssueController.php b/server/src/Http/Controllers/Api/v1/IssueController.php index 3d6155f90..55f6d7e39 100644 --- a/server/src/Http/Controllers/Api/v1/IssueController.php +++ b/server/src/Http/Controllers/Api/v1/IssueController.php @@ -158,7 +158,7 @@ public function delete($id) return $this->deletedIssueResource($issue); } - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { return Driver::findRecordOrFail($id); } diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 152479dec..bc4054d9a 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -1873,7 +1873,7 @@ protected function newPayload(): Payload return new Payload(); } - protected function findDriverByPublicId(string $publicId): ?Driver + protected function findDriverByPublicId(?string $publicId): ?Driver { return Driver::where(['public_id' => $publicId, 'company_uuid' => session('company')])->first(); } diff --git a/server/src/Http/Controllers/Api/v1/ServiceAreaController.php b/server/src/Http/Controllers/Api/v1/ServiceAreaController.php index f8726fd7c..b82fe4185 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceAreaController.php @@ -218,7 +218,7 @@ protected function createBorderFromPoint(Point $point, int $radius) return ServiceArea::createMultiPolygonFromPoint($point, $radius); } - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { return Utils::getUuid('service_areas', $where); } diff --git a/server/src/Http/Controllers/Api/v1/TrackingStatusController.php b/server/src/Http/Controllers/Api/v1/TrackingStatusController.php index 6a80f23a6..153c85d1c 100644 --- a/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +++ b/server/src/Http/Controllers/Api/v1/TrackingStatusController.php @@ -187,7 +187,7 @@ protected function getTrackingNumberUuid(string $table, array $where): ?string return Utils::getUuid($table, $where); } - protected function getOrderTrackingNumberUuid(string $orderId): ?string + protected function getOrderTrackingNumberUuid(?string $orderId): ?string { return Order::where('public_id', $orderId)->value('tracking_number_uuid'); } diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 5d5e72119..ffaff5dcd 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); @@ -409,19 +492,35 @@ protected function findVehicle(string $id): Vehicle return Vehicle::findRecordOrFail($id); } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { return Driver::findRecordOrFail($id); } 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/Controllers/Api/v1/ZoneController.php b/server/src/Http/Controllers/Api/v1/ZoneController.php index dba4e5af2..51adfe76c 100644 --- a/server/src/Http/Controllers/Api/v1/ZoneController.php +++ b/server/src/Http/Controllers/Api/v1/ZoneController.php @@ -212,7 +212,7 @@ protected function radiusFromRequest(Request $request): int return (int) $request->input('radius', 500); } - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { return Utils::getUuid('service_areas', $where); } diff --git a/server/src/Http/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/CreateServiceRateRequest.php b/server/src/Http/Requests/CreateServiceRateRequest.php index 304eafe4e..41122c58f 100644 --- a/server/src/Http/Requests/CreateServiceRateRequest.php +++ b/server/src/Http/Requests/CreateServiceRateRequest.php @@ -25,8 +25,13 @@ public function rules(): array return [ 'service_name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], 'service_type' => [Rule::requiredIf($this->isMethod('POST')), 'string'], - 'service_area' => [Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')], - 'zone' => [Rule::exists('zones', 'public_id')->whereNull('deleted_at')], + // `nullable` so an empty relationship is treated as absent rather than + // as an invalid one. Both are optional, but without it `exists` runs + // against the null that ConvertEmptyStringsToNull produced and answers + // "The selected service area is invalid" for a field the caller simply + // did not fill in. + 'service_area' => ['nullable', Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')], + 'zone' => ['nullable', Rule::exists('zones', 'public_id')->whereNull('deleted_at')], 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo,parcel'], 'currency' => ['required', 'size:3'], 'base_fee' => ['numeric'], diff --git a/server/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/ApiEntityControllerContractsTest.php b/server/tests/ApiEntityControllerContractsTest.php index 637274972..4f6efc5b8 100644 --- a/server/tests/ApiEntityControllerContractsTest.php +++ b/server/tests/ApiEntityControllerContractsTest.php @@ -43,7 +43,7 @@ protected function singularize(?string $value): ?string return $value === 'contacts' ? 'contact' : null; } - protected function findPayloadByPublicId(string $publicId): ?Payload + protected function findPayloadByPublicId(?string $publicId): ?Payload { $this->payload?->setAttribute('lookup_id', $publicId); diff --git a/server/tests/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/ApiFuelReportControllerContractsTest.php b/server/tests/ApiFuelReportControllerContractsTest.php index 3083a5fc3..ec22cc016 100644 --- a/server/tests/ApiFuelReportControllerContractsTest.php +++ b/server/tests/ApiFuelReportControllerContractsTest.php @@ -17,7 +17,7 @@ class FleetOpsApiFuelReportControllerProbe extends FuelReportController public bool $driverNotFound = false; public bool $fuelReportNotFound = false; - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); diff --git a/server/tests/ApiIssueControllerContractsTest.php b/server/tests/ApiIssueControllerContractsTest.php index 3492a80e6..167c1decf 100644 --- a/server/tests/ApiIssueControllerContractsTest.php +++ b/server/tests/ApiIssueControllerContractsTest.php @@ -17,7 +17,7 @@ class FleetOpsApiIssueControllerProbe extends IssueController public bool $driverNotFound = false; public bool $issueNotFound = false; - protected function findDriverRecord(string $id): Driver + protected function findDriverRecord(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index fb6860b64..cd5c8a4ff 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -80,7 +80,7 @@ protected function newPayload(): Payload return $this->payload = new FleetOpsApiOrderPayloadFake(); } - protected function findDriverByPublicId(string $publicId): ?Fleetbase\FleetOps\Models\Driver + protected function findDriverByPublicId(?string $publicId): ?Fleetbase\FleetOps\Models\Driver { $this->driver ??= new FleetOpsApiOrderDriverFake(); $this->driver->setRawAttributes([ diff --git a/server/tests/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/ApiServiceAreaZoneControllerContractsTest.php b/server/tests/ApiServiceAreaZoneControllerContractsTest.php index 8809e21a5..bd32bd95b 100644 --- a/server/tests/ApiServiceAreaZoneControllerContractsTest.php +++ b/server/tests/ApiServiceAreaZoneControllerContractsTest.php @@ -23,7 +23,7 @@ class FleetOpsApiServiceAreaControllerProbe extends ApiServiceAreaController public bool $notFound = false; public bool $createThrows = false; - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { $this->uuidLookups[] = [$publicId, $where]; @@ -116,7 +116,7 @@ class FleetOpsApiZoneControllerProbe extends ApiZoneController public mixed $queryResults = null; public bool $notFound = false; - protected function serviceAreaUuid(string $publicId, array $where): ?string + protected function serviceAreaUuid(?string $publicId, array $where): ?string { $this->uuidLookups[] = [$publicId, $where]; diff --git a/server/tests/ApiTrackingStatusControllerContractsTest.php b/server/tests/ApiTrackingStatusControllerContractsTest.php index e4682006b..f925820c4 100644 --- a/server/tests/ApiTrackingStatusControllerContractsTest.php +++ b/server/tests/ApiTrackingStatusControllerContractsTest.php @@ -32,7 +32,7 @@ protected function getTrackingNumberUuid(string $table, array $where): ?string return 'tracking-number-uuid'; } - protected function getOrderTrackingNumberUuid(string $orderId): ?string + protected function getOrderTrackingNumberUuid(?string $orderId): ?string { $this->orderLookups[] = $orderId; diff --git a/server/tests/ApiVehicleControllerContractsTest.php b/server/tests/ApiVehicleControllerContractsTest.php index fed856dce..92a05837e 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 @@ -51,7 +70,7 @@ protected function findVehicle(string $id): Vehicle return $this->vehicle; } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { if ($this->driverNotFound) { throw new ModelNotFoundException(); @@ -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/NullRelationshipInputTest.php b/server/tests/Feature/Http/Api/NullRelationshipInputTest.php new file mode 100644 index 000000000..05ccd1de4 --- /dev/null +++ b/server/tests/Feature/Http/Api/NullRelationshipInputTest.php @@ -0,0 +1,191 @@ +has('driver')` is still true — `has()` + * means the key is present, not that it holds anything. Every public + * relationship input therefore arrives as null sooner or later, usually from a + * form that serialises an unselected dropdown as an empty string. + * + * That was fine for years because the lookups were plain queries: + * `where('public_id', null)` matches nothing and the caller's `if ($driver)` + * skips. It broke when a coverage pass extracted one of those queries into + * `findDriverByPublicId(string $publicId)` — the *return* type was made + * nullable, the parameter was not — turning "no driver" into a TypeError on + * `POST /v1/orders`. + * + * The static test below is the one that would have caught it: it holds the + * property rather than the instance, so the next extraction cannot reintroduce + * the class. + */ +function fleetopsNullRelationshipBoot(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + foreach (['drivers', 'users', 'payloads', 'orders', 'service_areas', 'entities', 'waypoints', 'places'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'tracking_number_uuid', 'payload_uuid', 'place_uuid', 'name', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-uuid']); + $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']); + $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_real01', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']); + $connection->table('payloads')->insert(['uuid' => 'payload-uuid', 'public_id' => 'payload_real01', 'company_uuid' => 'company-uuid']); + $connection->table('orders')->insert(['uuid' => 'order-uuid', 'public_id' => 'order_real01', 'company_uuid' => 'company-uuid', 'tracking_number_uuid' => 'tracking-uuid']); + $connection->table('service_areas')->insert(['uuid' => 'sa-uuid', 'public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']); + + return $connection; +} + +function fleetopsInvokeSeam(object $controller, string $method, ...$arguments) +{ + $reflection = new ReflectionMethod($controller, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($controller, ...$arguments); +} + +test('every request-fed lookup seam answers rather than raising when handed nothing', function () { + fleetopsNullRelationshipBoot(); + + // Each of these is reached from `$request->input('')`. A null + // means the caller sent an empty relationship, which is a question with an + // answer — "there is no such record" — not a programming error. + expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', null))->toBeNull() + ->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', null))->toBeNull() + ->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', null))->toBeNull() + ->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull() + ->and(fleetopsInvokeSeam(new ZoneController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull(); + + // Issue and FuelReport reach their seam only from `create`, where `driver` + // is `required` — validation answers 422 long before a null could arrive. + // Widening them is defence-in-depth for a future call site, so the claim + // here is only the one that matters: whatever they answer, it is not a + // TypeError. A TypeError would escape this block and fail the test. + foreach ([new IssueController(), new FuelReportController()] as $controller) { + try { + fleetopsInvokeSeam($controller, 'findDriverRecord', null); + } catch (ModelNotFoundException) { + // "No such driver" — the call sites already translate this into a 404. + } + } +}); + +test('a real identifier still resolves through the widened seams', function () { + fleetopsNullRelationshipBoot(); + + // Widening the parameter must not have widened the lookup: a present + // identifier resolves exactly as before, and one that does not exist still + // resolves to nothing. + expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_real01')->uuid)->toBe('driver-uuid') + ->and(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_missing'))->toBeNull() + ->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', 'payload_real01')->uuid)->toBe('payload-uuid') + ->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', 'order_real01'))->toBe('tracking-uuid') + ->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', 'service_area_r1', ['public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']))->toBe('sa-uuid'); +}); + +test('no public api lookup seam fed from the request refuses a null identifier', function () { + // The systemic guard. Rather than pinning the eight seams that exist today, + // it derives them: every call of the form `$this->someSeam($request->input(...))` + // in the public v1 controllers, checked for a nullable first parameter. + // + // An extraction that types a new seam `string` fails here, at the commit + // that introduces it, instead of in production on an empty dropdown. + // Exempt only where the *call site* already excludes null, and say which + // guard does it — an exemption without a named guard is how this test would + // rot into a rubber stamp. + $exempt = [ + // Not an identifier lookup — it geocodes a free-text address, and a null + // address has no meaning to geocode. Its call sites guard with + // `$request->isString(...)`, which a null can never satisfy. + 'PlaceController::createPlaceFromGeocodingLookup', + // Both call sites are behind `$request->isArray('payload')`. A payload + // sent as `""` fails that guard and is skipped, never unpacked. + 'OrderController::payloadShapeFromArray', + ]; + + $offenders = []; + $inspected = []; + + foreach (glob(dirname(__DIR__, 4) . '/src/Http/Controllers/Api/v1/*.php') as $path) { + $source = file_get_contents($path); + $controller = basename($path, '.php'); + + preg_match_all('/\$this->([A-Za-z_]+)\(\s*\$request->(?:input|or)\(/', $source, $matches); + + foreach (array_unique($matches[1]) as $seam) { + if (in_array($controller . '::' . $seam, $exempt, true)) { + continue; + } + + if (!preg_match('/(?:protected|public|private) function ' . preg_quote($seam, '/') . '\(([^),]*)/', $source, $signature)) { + continue; + } + + $firstParameter = trim($signature[1]); + $inspected[] = $controller . '::' . $seam; + + // `mixed`, an untyped parameter, and anything explicitly nullable all + // accept null already; only a narrow non-nullable type is a problem. + $accepts = $firstParameter === '' + || str_starts_with($firstParameter, '?') + || str_starts_with($firstParameter, '$') + || str_contains($firstParameter, 'null') + || str_starts_with($firstParameter, 'mixed'); + + if (!$accepts) { + $offenders[] = $controller . '::' . $seam . '(' . $firstParameter . ')'; + } + } + } + + // A guard that scans nothing passes for the wrong reason, so make the scan + // prove itself: the seam this bug was reported against has to be among what + // it looked at. If the glob or the pattern ever stops matching, this fails + // instead of going quietly green. + expect($inspected)->toContain('OrderController::findDriverByPublicId') + ->and($offenders)->toBe([], 'these seams are handed a request value and would raise a TypeError on an empty relationship: ' . implode(', ', $offenders)); +}); diff --git a/server/tests/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..9cda5fd89 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']) @@ -954,7 +1041,13 @@ public function parameter($key, $default = null) ->and(ruleStrings($rateRules['peak_hours_calculation_method']))->toContain('required', 'in:percentage,flat') ->and(ruleStrings($rateRules['peak_hours_percent']))->toContain('required', 'integer') ->and(ruleStrings($rateRules['peak_hours_start']))->toContain('required', 'date_format:H:i') - ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i'); + ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i') + // Both relationships are optional, so an empty one has to read as + // absent. Without `nullable` the `exists` rule runs against the null + // that ConvertEmptyStringsToNull produced and answers "the selected + // service area is invalid" for a field the caller left blank. + ->and(ruleStrings($rateRules['service_area']))->toContain('nullable') + ->and(ruleStrings($rateRules['zone']))->toContain('nullable'); // A street address alone satisfies the name requirement on create, and // updates never require either field since the record already exists diff --git a/server/tests/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(); +}); diff --git a/server/tests/VehicleControllerHelperContractsTest.php b/server/tests/VehicleControllerHelperContractsTest.php index 3f2c63da4..67a150793 100644 --- a/server/tests/VehicleControllerHelperContractsTest.php +++ b/server/tests/VehicleControllerHelperContractsTest.php @@ -39,7 +39,7 @@ protected function resolveVehicle(string $id): ?Vehicle return $this->vehicle; } - protected function findDriver(string $id): Driver + protected function findDriver(?string $id): Driver { $this->driverLookups[] = $id;