Expand public Fleet, Vehicle, and Driver API contracts - #311
Open
roncodes wants to merge 2 commits into
Open
Conversation
The public v1 API exposed a small subset of what these records can hold, and the
gaps were silent rather than loud: a caller sending a field the controller did
not copy received a 200 and a response body that looked correct while the value
was discarded.
Fleets
- Create and update accept name, color, task, status, and the service_area,
zone, vendor and parent_fleet relationships as public ids. Only name and
service_area were reachable before, so a fleet hierarchy could not be built
through the API at all.
- parent_fleet: null clears a parent. A fleet may not be its own parent, nor sit
beneath one of its own descendants; both answer 422.
- Four public membership endpoints, all taking public ids and sharing one
response shape:
POST|DELETE /v1/fleets/{fleet}/vehicles/{vehicle}
POST|DELETE /v1/fleets/{fleet}/drivers/{driver}
Assignment is idempotent and restores a soft-deleted membership rather than
duplicating it; removal is a safe no-op and touches only the pivot.
Vehicles
- The input projection covered 21 of the model's 99 fields; it now covers all 90
safe ones, with type-appropriate validation for each.
- vendor, category, warranty and photo resolve from public ids.
- The create-time `online` default no longer applies to updates, where it
silently took a vehicle offline on any partial write.
Drivers
- Replaces an except() blocklist with an explicit allowlist. Anything nobody had
thought to exclude — auth_token, user_uuid, company_uuid — reached
Driver::create() intact, while location, heading, altitude, speed and meta
were dropped on every write.
- email and phone are optional. An operational record may have neither; nothing
is invented to fill the gap, and no invitation is sent when there is nowhere
to send one. Such a driver cannot sign in to Navigator until credentials are
supplied.
- Driver::$fillable held 'meta,' — a trailing comma inside the string — so meta
was never mass assignable.
- Driver photo upload wrote photo_uuid to users, which has no such column, so
every photo uploaded through the public API was dropped.
Tenant isolation
- Relationship inputs are validated with company-scoped exists rules and
resolved again through a company-scoped lookup. A cross-company public id is
answered exactly as a missing one, so a response cannot be used to probe
another organization's data.
- Relationship filters resolved public ids against uuid columns and so could
never match. FleetFilter::query searched a `user` relation Fleet does not
have, DriverFilter::phone a `phone` relation that does not exist, and
FleetFilter::zone a zone_uuid column zones does not have.
- Public responses report relationships as public ids; no *_uuid column appears
in a public payload. Internal console responses keep their existing shape.
Validation: php scripts/pest-file-runner.php — 434 files, exit 0.
composer test:lint reports 4 files, all pre-existing on origin/main and none
touched here. composer test:types fails on a pre-existing 13,739-error baseline;
the four new source files report zero.
6 tasks
The coverage gate caught 17 statements the new code added but no test entered. Every one is now reached by a test that asserts the behaviour, not by a call made only to move the number. - CreateFleetRequest::attributes() — asserted in RequestContractsTest, which already pins the rest of the fleet request contract. - PublicRelationNotFoundException::getRelation()/getIdentifier() — covered in ExceptionContractsTest alongside the other FleetOps exceptions, including the null-identifier case. - ResolvesPublicRelationUuids' blank-identifier early return — a filter given an empty value must resolve to nothing without reaching the database. - DriverFilter's console uuid branch — Http::isInternalRequest() reads the resolved route's uri rather than the request path, so the branch needs a request with an internal route resolver to be reachable at all. The test now builds one, which is also what proves the branch is internal-only. - FleetController: the update path's cross-company relationship rejection (the create path was already covered), removeVehicle's and removeDriver's not-found answers, and the real bodies of findVehicle, findDriver, withPublicRelations and queryFleets — the last four exercised against SQLite in FleetPublicContractTest, which asserts that the lookups are company-scoped and that the query pipeline eager loads the relations the public resource reports as public ids. Local baseline: 100.00% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes. The statement total matches the figure CI reported exactly, so the 17 closed here are precisely the ones it flagged. php scripts/pest-file-runner.php: 434 files, exit 0.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #311 +/- ##
============================================
Coverage 100.00% 100.00%
- Complexity 9899 9956 +57
============================================
Files 526 530 +4
Lines 38163 38457 +294
============================================
+ Hits 38163 38457 +294
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver
records can actually hold, and the gaps were silent rather than loud.
POST /v1/fleetsaccepted onlynameandservice_area. Colour, task,status, zone, vendor and — most consequentially —
parent_fleetwereunreachable, so a fleet hierarchy could not be built through the API at all.
added to a fleet through the console's internal routes, which take uuids.
VehicleControllercopied 21 of the model's 99 fields out of a request. Acaller sending
weight,gvwr,purchased_atorengine_numberreceived a200and a response body that looked correct while the value was discarded.DriverControllerused anexcept()blocklist, so anything nobody hadthought to exclude reached
Driver::create()intact — includingauth_token,user_uuidandcompany_uuid. At the same time the blocklist droppedlocation,heading,altitude,speedandmetaon every write.Driver::$fillablelisted'meta,'— with a trailing comma inside thestring — so
metawas never mass assignable at all.so
?vendor=,?fleet=and the fleet hierarchy filters could never match.FleetFilter::query()searched auserrelation that Fleet does not have,and
DriverFilter::phone()searched aphonerelation that does not exist —both raise rather than filter.
exists:rules, so anotherorganization's public ID passed validation and was then resolved to nothing:
the write was accepted and the relationship silently dropped.
Field-parity matrix
Every field on each model is classified. Legend:
inside the authenticated company
Fleet — 13 fillable columns
namenamecolorcolortasktaskstatusstatusactive/disabled/decommissioned, and the importer and existing integrations write other values. Validated as a short string so the public API can express any state the console can.service_area_uuidservice_areazone_uuidzonevendor_uuidvendorparent_fleet_uuidparent_fleetimage_uuidphotofile_...public ID, matchingPartControllerpublic_idslugnamebyHasSlugcompany_uuid_keyuuidis not fillable and is never accepted or returned publicly.Vehicle — 99 fillable columns
Class 1 — public scalar input (90, all newly accepted unless marked):
name,description,make,model,model_type,year,trim,color,type,class,internal_id,plate_number,vin,serial_number,call_sign,fuel_card_number,odometer*,odometer_unit*,odometer_at_purchase,measurement_system,fuel_type,fuel_volume_unit,online*,status*,location*,heading*,altitude*,speed*,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,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,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,specs,details,notes,meta*,skills*,payload_capacity_volume*,payload_capacity_pallets*,payload_capacity_parcels*,max_tasks*,time_window_start*,time_window_end*,return_to_depot** already accepted before this change.
Class 2 — public relationship input:
vendor_uuidvendorcategory_uuidcategorywarranty_uuidwarrantyphoto_uuidphotofile_...public IDdriverDriver::assignVehicle; now company-scopedClasses 3–5 — intentionally excluded:
company_uuidvendor_uuid,category_uuid,warranty_uuid,photo_uuidslugvin_dataapplyAllDataFromVin()re-runs whenevervinchangestelematicsavatar_urlphotowith afile_...public ID instead.Driver — 31 fillable columns, plus the linked user account
Class 1 — public scalar input:
internal_id,drivers_license_number,license_expiry,country,currency,city,online,current_status,status,location(and
latitude/longitude),heading,bearing,altitude,speed,meta,skills,max_travel_time,max_distance,time_window_start,time_window_endUser-account fields (stored on
users, not ondrivers):name(required on create),email,phone,timezone, andpasswordoncreate only.
Class 2 — public relationship input:
vehicle_uuidvehiclevendor_uuidvendorcurrent_job_uuidjobusers.avatar_uuidphotoClasses 3–5 — intentionally excluded:
auth_tokensignup_token_useduser_uuidcompany_uuidvehicle_uuid,vendor_uuid,current_job_uuidpublic_id,slug_keyavatar_urlphotopasswordis accepted on create and deliberately not on update: changing apassword requires proving the old one and resetting it requires a code, neither
of which a general
PUTcan express.POST /v1/drivers/{id}/change-password,forgot-passwordandreset-passwordremain the only ways to change it.Fleet hierarchy contract
parent_fleetcreates a root fleet."parent_fleet": nullon update promotes a subfleet back to a root fleet;every optional relationship clears the same way.
422.422. Thecheck walks upward from the proposed parent, so an arbitrarily deep cycle is
caught, and a
$seenset makes the walk terminate even againstalready-corrupt data.
The two are answered identically, so a response cannot be used to probe
whether another organization holds a given public ID.
name, or onlynameandservice_area, behaveexactly as before.
Fleet membership endpoints
All four take public IDs and answer in one shape:
{ "fleet": "fleet_123", "vehicle": "vehicle_123", "assigned": true } { "fleet": "fleet_123", "driver": "driver_123", "assigned": true }findRecordOrFail, whichis company-scoped, so a cross-company resource is unavailable rather than
forbidden — the same
404an id that does not exist gets.firstOrNewoverwithTrashed()means a repeatcreates no second pivot row, and a membership that was previously removed is
restored rather than shadowed by a duplicate. Both pivots carry soft deletes,
which is why this matters.
deleted, the driver's
vehicle_uuidis untouched, and memberships of otherfleets are unaffected.
{id}patterns so a literalvehiclesordriverssegment can never be read as a fleet id.Drivers without credentials
emailandphoneare now optional on create. Both are still validated forformat and uniqueness when supplied, and existing email-and-phone creation is
unchanged.
userstable already declaresemailandphonenullable with no uniqueconstraint, so the schema supports this without a migration.
tenant's user table looking real, could be mailed to, and would block the
genuine value later.
User::sendInviteFromCompany()already returns earlywhen there is no email address. A regression test pins that.
driveruser type and the
Driverrole are all created exactly as they are for acredentialed driver.
supplied. Add an email address or phone number with
PUT /v1/drivers/{id}when one becomes available.
Public-ID and tenant-isolation guarantees
existsrules scoped tocompany_uuidanddeleted_at is null, then resolved again in the controllerthrough the company-scoped
resolveModel(). Validation and resolution bothenforce the boundary.
ResolvesFleetOpsApiResources::resolveUuid()/resolveModel()take anoptional company uuid.
DriverController::createpasses the company itresolved, because 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.
ResolvesPublicRelationUuidstrait, scoped to the session company. The rawuuid arm stays available to internal console requests only.
*_uuidcolumnappears in a public payload.
Filters corrected
VehicleFilter::internalIdinternal_idVehicleFilter::driveruuidVehicleFilter::fleetfleet_uuidVehicleFilter::vendorDriverFilter::facilitator/vendorvendor_uuidDriverFilter::vehicleDriverFilter::fleetfleet_uuidDriverFilter::phonewhereHas('phone')— not a relation, raisesFleetFilter::querywhereHas('user')— not a relation on Fleet, raisesname,task,public_idFleetFilter::serviceArea/zone/vendoruuid, andzoneagainst azone_uuidcolumn thatzonesdoes not haveFleetFilter::parentFleetwhereHas('parent_fleet')— wrong relation nameparent_fleet_uuidDefects fixed along the way
Driver::$fillableheld'meta,', so driver metadata could never be massassigned. Corrected, with a regression test.
photo_uuidtousers, which has no such column —Userguards mass assignment by fillable, so every photo uploaded through thepublic API was dropped without a word. Now writes
avatar_uuid.VehicleController::updateapplied the create-timeonlinedefault, so anypartial update — a plate correction, an odometer reading — silently took the
vehicle offline. The default is now create-only.
count()queries per fleet on every publicrequest and then discarded the results, because
when()evaluates a plainvalue argument eagerly. They are closures now.
parent_fleeton$this->serviceArea, so asubfleet with no service area never reported its parent.
Backward compatibility
All nineteen vehicle statuses are preserved, including
active, which themodel continues to store as
available.that were previously absent on public requests (because nothing loaded them)
are now present as public IDs. A relationship asked for through
?with=stillreturns the nested object it always did.
Http::isInternalRequest()selectsthe previous
whenLoadedshape.relationship public ID is now rejected instead of being silently dropped, and
driver input is an allowlist instead of a blocklist, so fields such as
auth_tokenno longer reach the model.ResolvesFleetOpsApiResources::resolveUuid(),resolveModel()andapplyPublicIdRelation()gained a trailing optional?string $companyUuid = null.Five unrelated test doubles that override them were updated to match; no
behaviour changed.
Tests
Extended:
ApiFleetControllerContractsTest— full-field create, root vs subfleet, parentclearing, self-parent and descendant-cycle rejection, cross-company rejection
for all four relationships, the input allowlist, and the membership response
shape and 404s.
ApiVehicleControllerContractsTest— a data-driven parity test over all 90scalar inputs, relationship resolution, relationship clearing, cross-company
rejection, and the excluded-column allowlist.
ApiDriverControllerContractsTest— parity over the driver's scalar inputs,the excluded-column allowlist,
meta/location/telemetry persistence, creationwith no credentials and with one contact method, cross-company rejection,
relationship clearing, and company-scoped relationship resolution.
RequestContractsTest— company scoping recorded on every relationship rule,the vehicle status enum, per-type vehicle rules, and the optional
email/phone contract.
ControllerFilterContractsTest,DriverFilterExecutionTest— the correctedrelationship filters, asserted against resolved uuids on a real connection.
Added:
Feature/Http/Api/FleetMembershipTest— database-backed pivot semantics:idempotent assignment with no duplicate row, restore of a soft-deleted
membership, repeated removal as a no-op, and preservation of the driver's
vehicle and of unrelated fleet memberships.
Feature/Http/Api/FleetPublicContractTest— public field parity and public-IDrelationships on the Fleet resource, the null-parent root case, the unchanged
internal shape,
?with=still nesting, and route registration and orderingfor the membership endpoints.
Validation
composer test:unitruns the same per-file Pest runner as the first command.Coverage gate — 100% on all three metrics:
The first push of this branch left the gate at 99.95% (34653/34670): 17
statements the new code added that no test entered. The second commit closes
all 17 — the statement total matches CI's figure exactly, so those are precisely
the ones it flagged. Each is reached by a test that asserts the behaviour rather
than by a call made only to move the number; the commit message lists them.
composer test:lintreports 4 files, all pre-existing onorigin/mainandnone of them touched by this branch:
Verified pre-existing by running
php-cs-fixeragainst theorigin/maincopyof
GeofenceController.php, which reports the same fixable file. Every filethis branch touches is clean.
composer test:typesreports 13,739 errors atlevel: max. This is thepre-existing baseline — the project has no PHPStan baseline file and no Laravel
extension, so every framework magic property, every
session()and everyconfig()call is an error.The four new source files report zero errors:
Per-file counts for the modified sources,
origin/mainagainst this branch:The net +29 is entirely the same baseline noise scaling with the number of
fields and seam methods added —
Access to an undefined property,has no return type specified,Function session not found. Every one of thenew entries matches a shape already present in the same file on
main.Unrelated CI job
API Contract (Postman) / Postman contract against live APIfails on this PR.It fails identically on
main— run33866120783,
from the same day — with the same error:
The job boots a stack and runs
fleetbase/postman@main; it resolves nothingfrom this repository's diff, and the collection runs locally without that error.
Pre-existing and out of scope here.
Related
Documentation and contract tests: fleetbase/postman#59 — that PR
documents and tests this one.
If the
fleetbase.ioAPI reference is generated fromfleetbase/postman, itneeds regeneration once both PRs land. No change was made to
fleetbase/fleetbase.io.Confirmation
No customer-specific code, fixtures or data was added. Nothing here is specific
to any one importer, spreadsheet or customer: every change is a general
Fleet-Ops public API improvement. No credentials or API keys are included, and
no production configuration was changed.