name); ?>
@@ -123,7 +123,7 @@
-
 ? $trackingNumber->barcode : $order->trackingNumber->barcode; ?>)
+
tracking_number : $order->trackingNumber->tracking_number)); ?>
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php
index bc4054d9a..bd99b72a2 100644
--- a/server/src/Http/Controllers/Api/v1/OrderController.php
+++ b/server/src/Http/Controllers/Api/v1/OrderController.php
@@ -25,6 +25,7 @@
use Fleetbase\FleetOps\Models\ServiceQuote;
use Fleetbase\FleetOps\Models\Waypoint;
use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops;
+use Fleetbase\FleetOps\Support\TrackingCode;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Resources\Comment as CommentResource;
@@ -1444,8 +1445,8 @@ public function captureQrScan(Request $request, string $id, ?string $subjectId =
return $this->apiError('Unable to capture QR code data.');
}
- // validate
- if ($subject && $code === $subject->uuid) {
+ // validate: the scan must name this subject, in any code format still in circulation
+ if (TrackingCode::matches($code, $subject)) {
// create verification proof
$proof = $this->createProof([
'company_uuid' => session('company'),
diff --git a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php
index d79658fcc..2c969163f 100644
--- a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php
+++ b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php
@@ -127,8 +127,10 @@ public function fromQR(DecodeTrackingNumberQR $request)
// validate request inputs
$code = $request->input('code');
- // get the model of from the code
- $model = $this->findQrModel(['entities', 'orders'], ['uuid' => $code]);
+ // the order, waypoint, entity or place the code points at, in any format still in
+ // circulation: a tracking url, a bare tracking number or public id, or the bare
+ // owner uuid printed on older labels
+ $model = $this->findQrModel($code);
// if no model response with error
if (!$model) {
@@ -179,37 +181,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber)
return new DeletedResource($trackingNumber);
}
- protected function findQrModel(array $tables, array $where)
+ protected function findQrModel(string $code)
{
- // Hydrate through the eloquent models so the resolved record can be
- // wrapped in its typed resource — raw rows have no model class.
- $modelMap = [
- 'entities' => \Fleetbase\FleetOps\Models\Entity::class,
- 'orders' => \Fleetbase\FleetOps\Models\Order::class,
- ];
-
- foreach ($tables as $table) {
- $modelClass = $modelMap[$table] ?? null;
- if (!$modelClass) {
- continue;
- }
-
- $model = $modelClass::where($where)->first();
- if ($model) {
- return $model;
- }
- }
-
- // No raw-row fallback. Utils::findModel() was called with the ARRAY of table names
- // and, when nothing matched, passed that array to DB::table() — which stringified
- // it to the literal table name "Array" and threw SQLSTATE[42S02]. Every
- // unresolvable code became a 500 instead of the 400 this method's caller already
- // handles.
- //
- // The fallback could not have helped anyway: qrModelResource() serializes through
- // a typed resource keyed on the model class, and a raw stdClass row has none. The
- // mapped models above are the endpoint's whole contract.
- return null;
+ return TrackingNumber::findOwnerByCode($code, session('company'));
}
protected function qrModelResource($model)
diff --git a/server/src/Http/Resources/v1/TrackingNumber.php b/server/src/Http/Resources/v1/TrackingNumber.php
index bed9b0d5d..2f5c62ef6 100644
--- a/server/src/Http/Resources/v1/TrackingNumber.php
+++ b/server/src/Http/Resources/v1/TrackingNumber.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Resources\v1;
+use Fleetbase\FleetOps\Support\TrackingCode;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Resources\FleetbaseResource;
use Fleetbase\Support\Http;
@@ -30,47 +31,20 @@ public function toArray($request)
'status' => $this->last_status,
'status_code' => $this->last_status_code,
'qr_code' => $this->qr_code,
- // The raw text encoded inside qr_code, exposed ONLY in debug mode.
- //
- // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode),
- // and the endpoints that consume a scanned code match on that uuid. A client
- // scans the image to obtain it; an automated contract run cannot, so debug
- // builds publish the value beside the image rather than the API growing a
- // decode endpoint or handing out uuids generally.
- //
- // `when()` omits the key entirely when false — it is absent, not null — so a
- // production response is byte-identical to before.
- 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid),
+ // A value that scans as this tracking number: the QR content current labels
+ // print (see TrackingCode). It names the tracking number and the subject's
+ // public id, both already in this resource, so it is safe to publish, and lets
+ // clients that print their own labels, or cannot decode a PNG, use the code.
+ // Labels printed before it encode the owner uuid; resolvers accept both.
+ 'qr_code_content' => $this->when($this->tracking_number, fn () => TrackingCode::qrContent($this->tracking_number, Utils::get($this->owner, 'public_id'))),
'barcode' => $this->barcode,
- 'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]),
+ 'url' => Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->tracking_number]),
'type' => Utils::getTypeFromClassName($this->owner_type),
'updated_at' => $this->updated_at,
'created_at' => $this->created_at,
];
}
- /**
- * Whether the QR code's decoded content may be published.
- *
- * Debug mode only, and fails closed: any failure to determine the debug state — no
- * container, an application without the accessor — answers false rather than
- * defaulting to exposure.
- */
- protected static function exposesQrCodeContent(): bool
- {
- try {
- $app = app();
-
- if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) {
- return false;
- }
-
- return (bool) $app->hasDebugModeEnabled();
- } catch (\Throwable $e) {
- return false;
- }
- }
-
/**
* Transform the resource into an webhook payload.
*
diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php
index 032e2950b..224d15ec0 100644
--- a/server/src/Models/Entity.php
+++ b/server/src/Models/Entity.php
@@ -471,10 +471,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null)
if ($result && $payload) {
// create tracking number for entity
$trackingNumberId = TrackingNumber::insertGetUuid([
- 'owner_uuid' => $uuid,
- 'owner_type' => Utils::getModelClassName('entity'),
- 'region' => $payload->getPickupRegion(),
- 'location' => Utils::parsePointToWkt($payload->getPickupLocation()),
+ 'owner_uuid' => $uuid,
+ 'owner_type' => Utils::getModelClassName('entity'),
+ 'owner_public_id' => $values['public_id'],
+ 'region' => $payload->getPickupRegion(),
+ 'location' => Utils::parsePointToWkt($payload->getPickupLocation()),
]);
// set tracking number
diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php
index e5b78defd..68ae9bd15 100644
--- a/server/src/Models/TrackingNumber.php
+++ b/server/src/Models/TrackingNumber.php
@@ -4,6 +4,7 @@
use Fleetbase\Casts\PolymorphicType;
use Fleetbase\FleetOps\Casts\Point;
+use Fleetbase\FleetOps\Support\TrackingCode;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait;
use Fleetbase\Models\Model;
@@ -16,6 +17,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
+use Milon\Barcode\Facades\DNS1DFacade as DNS1D;
use Milon\Barcode\Facades\DNS2DFacade as DNS2D;
class TrackingNumber extends Model
@@ -236,6 +238,74 @@ public static function findTrackingOrFail($id)
throw (new \Illuminate\Database\Eloquent\ModelNotFoundException())->setModel(static::class, $id);
}
+ /**
+ * The record a scanned code points at, within one company: the order, waypoint,
+ * entity or place that owns the tracking number.
+ *
+ * Accepts every format TrackingCode::parse() reads, including the bare owner uuid
+ * printed on labels before codes carried a url, and a bare tracking number or public
+ * id from a barcode or typed by hand. Without a company nothing resolves.
+ */
+ public static function findOwnerByCode(?string $code, ?string $companyUuid): ?\Illuminate\Database\Eloquent\Model
+ {
+ if (empty($companyUuid)) {
+ return null;
+ }
+
+ $parsed = TrackingCode::parse($code);
+
+ if ($parsed['uuid']) {
+ return static::ownerOf(static::firstForCode('owner_uuid', $parsed['uuid'], $companyUuid));
+ }
+
+ $number = $parsed['tracking_number'] ?? $parsed['reference'];
+ if ($number) {
+ $owner = static::ownerOf(static::firstForCode('tracking_number', $number, $companyUuid));
+
+ // A url names its tracking number deliberately, so an unknown one is a miss,
+ // not a cue to fall back to the owner it also names, which must agree.
+ if ($owner || $parsed['tracking_number']) {
+ return $owner && (!$parsed['public_id'] || data_get($owner, 'public_id') === $parsed['public_id']) ? $owner : null;
+ }
+ }
+
+ $publicId = $parsed['public_id'] ?? $parsed['reference'];
+
+ return $publicId ? static::ownerOf(static::firstForOwnerPublicId($publicId, $companyUuid)) : null;
+ }
+
+ protected static function firstForCode(string $column, string $value, string $companyUuid): ?self
+ {
+ return static::where('company_uuid', $companyUuid)->where($column, $value)->first();
+ }
+
+ protected static function firstForOwnerPublicId(string $publicId, string $companyUuid): ?self
+ {
+ // The public id's prefix names the owner's model, so only that table is searched.
+ $prefix = Str::contains($publicId, '_') ? Str::before($publicId, '_') : null;
+ $ownerClass = [
+ 'order' => Order::class,
+ 'waypoint' => Waypoint::class,
+ 'entity' => Entity::class,
+ 'place' => Place::class,
+ ][$prefix] ?? null;
+
+ if (!$ownerClass) {
+ return null;
+ }
+
+ return static::where('company_uuid', $companyUuid)
+ // Rows inserted raw (waypoints, entities) store the class with a leading slash.
+ ->whereIn('owner_type', [$ownerClass, '\\' . $ownerClass])
+ ->whereIn('owner_uuid', $ownerClass::withoutGlobalScopes()->select('uuid')->where('public_id', $publicId))
+ ->first();
+ }
+
+ protected static function ownerOf(?self $trackingNumber): ?\Illuminate\Database\Eloquent\Model
+ {
+ return $trackingNumber?->owner;
+ }
+
public function updateOwnerStatus(?TrackingStatus $trackingStatus = null)
{
$trackingStatus = $trackingStatus ?? $this->load(['status'])->getRelationValue('status');
@@ -254,6 +324,10 @@ public function updateOwnerStatus(?TrackingStatus $trackingStatus = null)
public static function insertGetUuid($values = [], ?Model $owner = null)
{
+ // Read before the fillable filter below drops it. Callers that insert the owner
+ // with a raw query, and so have no model to pass, hand its public_id over here.
+ $ownerPublicId = $owner ? data_get($owner, 'public_id') : ($values['owner_public_id'] ?? null);
+
$instance = new static();
$fillable = $instance->getFillable();
$insertKeys = array_keys($values);
@@ -276,8 +350,7 @@ public static function insertGetUuid($values = [], ?Model $owner = null)
}
$values['tracking_number'] = static::newTrackingNumber($values['region'] ?? 'SG');
- $values['qr_code'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'QRCODE');
- $values['barcode'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'PDF417');
+ $values = array_merge($values, static::codeImages($values['tracking_number'], $ownerPublicId));
if (isset($values['meta']) && (is_object($values['meta']) || is_array($values['meta']))) {
$values['meta'] = json_encode($values['meta']);
@@ -339,8 +412,29 @@ protected static function newTrackingNumber(string $region): string
return static::generateNumber($region);
}
+ /**
+ * The base64 PNGs printed on a label: a QR code carrying the tracking url (see
+ * TrackingCode) and a Code 128 barcode carrying the bare tracking number.
+ *
+ * @return array{qr_code: string, barcode: string}
+ */
+ public static function codeImages(string $trackingNumber, ?string $ownerPublicId = null): array
+ {
+ return [
+ // Medium error correction: labels get scuffed, and the url fits comfortably.
+ 'qr_code' => static::newBarcode(TrackingCode::qrContent($trackingNumber, $ownerPublicId), 'QRCODE,M'),
+ 'barcode' => static::newBarcode(TrackingCode::barcodeContent($trackingNumber), 'C128'),
+ ];
+ }
+
protected static function newBarcode(string $value, string $type): string
{
+ if ($type === 'C128') {
+ // 2px modules, 60px tall: a typical tracking number renders ~330px wide, inside
+ // the label's 360px cap, so it prints at its own resolution.
+ return DNS1D::getBarcodePNG($value, $type, 2, 60);
+ }
+
return DNS2D::getBarcodePNG($value, $type);
}
diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php
index 35d611eb4..bfb895df3 100644
--- a/server/src/Models/Waypoint.php
+++ b/server/src/Models/Waypoint.php
@@ -221,10 +221,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null)
if ($result && $payload) {
// create tracking number for entity
$trackingNumberId = static::createTrackingNumber([
- 'owner_uuid' => $uuid,
- 'owner_type' => Utils::getModelClassName('waypoint'),
- 'region' => $payload->getPickupRegion(),
- 'location' => static::pickupLocationWkt($payload),
+ 'owner_uuid' => $uuid,
+ 'owner_type' => Utils::getModelClassName('waypoint'),
+ 'owner_public_id' => $values['public_id'],
+ 'region' => $payload->getPickupRegion(),
+ 'location' => static::pickupLocationWkt($payload),
]);
// set tracking number
diff --git a/server/src/Observers/TrackingNumberObserver.php b/server/src/Observers/TrackingNumberObserver.php
index c90ec3088..2b249ebee 100644
--- a/server/src/Observers/TrackingNumberObserver.php
+++ b/server/src/Observers/TrackingNumberObserver.php
@@ -6,7 +6,6 @@
use Fleetbase\FleetOps\Models\TrackingStatus;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
use Illuminate\Support\Str;
-use Milon\Barcode\Facades\DNS2DFacade as DNS2D;
class TrackingNumberObserver
{
@@ -17,10 +16,9 @@ class TrackingNumberObserver
*/
public function creating(TrackingNumber $trackingNumber)
{
- // generate a barcode annd qr code
+ // generate the tracking number, then the qr code and barcode printed from it
$trackingNumber->tracking_number = $this->generateTrackingNumber($trackingNumber);
- $trackingNumber->qr_code = $this->generateBarcode($trackingNumber->owner_uuid, 'QRCODE');
- $trackingNumber->barcode = $this->generateBarcode($trackingNumber->owner_uuid, 'PDF417');
+ $trackingNumber->fill($this->generateCodeImages($trackingNumber->tracking_number, $this->ownerPublicId($trackingNumber)));
}
/**
@@ -47,9 +45,23 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin
return TrackingNumber::generateNumber($trackingNumber->region);
}
- protected function generateBarcode(string $ownerUuid, string $type): string
+ /**
+ * @return array{qr_code: string, barcode: string}
+ */
+ protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array
{
- return DNS2D::getBarcodePNG($ownerUuid, $type);
+ return TrackingNumber::codeImages($trackingNumber, $ownerPublicId);
+ }
+
+ protected function ownerPublicId(TrackingNumber $trackingNumber): ?string
+ {
+ if (empty($trackingNumber->owner_uuid)) {
+ return null;
+ }
+
+ $publicId = data_get($trackingNumber, 'owner.public_id');
+
+ return is_string($publicId) && $publicId !== '' ? $publicId : null;
}
protected function createTrackingStatus(array $attributes): TrackingStatus
diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php
new file mode 100644
index 000000000..f043a8a53
--- /dev/null
+++ b/server/src/Support/TrackingCode.php
@@ -0,0 +1,219 @@
+/track-order?order=
&r=&v=1
+ *
+ * Only the tracking number (printed on the label in plain text) and the owner's public
+ * id go in: no uuids, no addresses, no names, no company. The owner's type is its
+ * public_id prefix (order_, waypoint_, entity_, place_), so it is not repeated. Nothing
+ * is signed: a label can be photocopied whatever it carries, so authorization belongs
+ * to the endpoint that acts on a scan, not to the code.
+ *
+ * The linear barcode (Code 128) carries the bare tracking number, matching the text
+ * printed beneath it, because warehouse handhelds type what they scan into a field.
+ *
+ * Codes already on parcels encode the owner's bare uuid, and parse() keeps accepting
+ * them, alongside a bare tracking number or a bare public_id.
+ */
+class TrackingCode
+{
+ public const VERSION = 1;
+
+ /**
+ * Where a phone camera lands. The public tracking page is being rebuilt; when its
+ * route changes, change these two and nothing else. parse() never depends on the
+ * host or the path, since printed labels outlive both.
+ */
+ public const PATH = 'track-order';
+ public const TRACKING_PARAM = 'order';
+
+ /**
+ * The QR content for a tracking number.
+ */
+ public static function qrContent(string $trackingNumber, ?string $ownerPublicId = null): string
+ {
+ $query = [static::TRACKING_PARAM => $trackingNumber];
+
+ if (!empty($ownerPublicId)) {
+ $query['r'] = $ownerPublicId;
+ }
+
+ $query['v'] = static::VERSION;
+
+ return static::consoleBaseUrl() . '/' . static::PATH . '?' . http_build_query($query);
+ }
+
+ /**
+ * The console origin, from the same config Utils::consoleUrl() reads. It does not
+ * call consoleUrl() because that infers http/https from the app environment: a code
+ * printed on a parcel should not change scheme with where it was generated, so it
+ * is https unless the console is explicitly configured otherwise.
+ */
+ protected static function consoleBaseUrl(): string
+ {
+ $host = rtrim((string) config('fleetbase.console.host', ''), '/');
+
+ if (Str::startsWith($host, 'http')) {
+ return $host;
+ }
+
+ $subdomain = config('fleetbase.console.subdomain');
+ $scheme = config('fleetbase.console.secure', true) ? 'https://' : 'http://';
+
+ return $scheme . ($subdomain ? $subdomain . '.' : '') . $host;
+ }
+
+ /**
+ * The linear barcode content for a tracking number.
+ */
+ public static function barcodeContent(string $trackingNumber): string
+ {
+ return $trackingNumber;
+ }
+
+ /**
+ * Break a scanned value into the identifiers it carries.
+ *
+ * - `uuid`: a legacy code, the owner's uuid.
+ * - `tracking_number` and `public_id`: from a URL code.
+ * - `reference`: a bare value that is either a tracking number (the barcode, or one
+ * typed by hand) or a public_id. Consumers try both.
+ *
+ * @return array{uuid: ?string, tracking_number: ?string, public_id: ?string, reference: ?string, version: ?int}
+ */
+ public static function parse(?string $code): array
+ {
+ $parsed = [
+ 'uuid' => null,
+ 'tracking_number' => null,
+ 'public_id' => null,
+ 'reference' => null,
+ 'version' => null,
+ ];
+
+ $code = trim((string) $code);
+ if ($code === '') {
+ return $parsed;
+ }
+
+ if (preg_match('#^https?://#i', $code)) {
+ return array_merge($parsed, static::parseUrl($code));
+ }
+
+ if (Str::isUuid($code)) {
+ $parsed['uuid'] = $code;
+
+ return $parsed;
+ }
+
+ $parsed['reference'] = $code;
+
+ return $parsed;
+ }
+
+ /**
+ * Whether a scanned value identifies the given subject (an order, waypoint, entity or
+ * place). The value comes straight from a request, so anything but a string, such as
+ * a scanner library's whole result object, matches nothing.
+ */
+ public static function matches($code, $subject): bool
+ {
+ $subjectUuid = data_get($subject, 'uuid');
+ if (!is_string($code) || !$subject || empty($subjectUuid)) {
+ return false;
+ }
+
+ // Labels printed before v1 encode the subject's bare uuid; compare it exactly, as
+ // scans always have been.
+ if (trim($code) === $subjectUuid) {
+ return true;
+ }
+
+ $parsed = static::parse($code);
+
+ if ($parsed['uuid']) {
+ return false;
+ }
+
+ $publicId = data_get($subject, 'public_id');
+ $trackingNumber = static::subjectTrackingNumber($subject);
+
+ if ($parsed['reference']) {
+ return static::sameTrackingNumber($parsed['reference'], $trackingNumber) || static::samePublicId($parsed['reference'], $publicId);
+ }
+
+ // A url code names its owner by public id, and that decides: an owner can hold more
+ // than one tracking number (the API issues extra ones), so the number is compared
+ // only when the code names no owner.
+ if ($parsed['public_id']) {
+ return static::samePublicId($parsed['public_id'], $publicId);
+ }
+
+ return $parsed['tracking_number'] !== null && static::sameTrackingNumber($parsed['tracking_number'], $trackingNumber);
+ }
+
+ /**
+ * @return array{tracking_number: ?string, public_id: ?string, version: ?int}
+ */
+ protected static function parseUrl(string $url): array
+ {
+ $parts = parse_url($url);
+ $query = [];
+ parse_str(is_array($parts) ? ($parts['query'] ?? '') : '', $query);
+
+ $trackingNumber = static::stringParam($query, static::TRACKING_PARAM) ?? static::stringParam($query, 'tn');
+
+ // A future route may carry the tracking number in the path (/track/). Only
+ // a path of two or more segments can, so /track-order itself is never mistaken
+ // for one.
+ if ($trackingNumber === null && is_array($parts) && isset($parts['path'])) {
+ $segments = array_values(array_filter(explode('/', $parts['path']), fn ($segment) => $segment !== ''));
+ if (count($segments) >= 2) {
+ $trackingNumber = rawurldecode(end($segments));
+ }
+ }
+
+ $version = static::stringParam($query, 'v');
+
+ return [
+ 'tracking_number' => $trackingNumber,
+ 'public_id' => static::stringParam($query, 'r'),
+ 'version' => $version !== null && ctype_digit($version) ? (int) $version : null,
+ ];
+ }
+
+ protected static function stringParam(array $query, string $key): ?string
+ {
+ $value = $query[$key] ?? null;
+
+ return is_string($value) && trim($value) !== '' ? trim($value) : null;
+ }
+
+ protected static function subjectTrackingNumber($subject): ?string
+ {
+ $trackingNumber = data_get($subject, 'trackingNumber.tracking_number') ?? data_get($subject, 'tracking_number');
+
+ return is_string($trackingNumber) && $trackingNumber !== '' ? $trackingNumber : null;
+ }
+
+ protected static function sameTrackingNumber(string $scanned, ?string $trackingNumber): bool
+ {
+ // The label prints the number upper-cased and handhelds may not preserve case.
+ return $trackingNumber !== null && strcasecmp($scanned, $trackingNumber) === 0;
+ }
+
+ protected static function samePublicId(string $scanned, $publicId): bool
+ {
+ return is_string($publicId) && $publicId !== '' && $scanned === $publicId;
+ }
+}
diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php
index cd5c8a4ff..d22c0394d 100644
--- a/server/tests/ApiOrderControllerContractsTest.php
+++ b/server/tests/ApiOrderControllerContractsTest.php
@@ -1028,3 +1028,54 @@ public function isString(string $key): bool
'status' => 400,
]);
});
+
+test('api order controller captures QR proof from tracking url codes and barcodes', function () {
+ session(['company' => 'company-uuid']);
+
+ $subject = (object) [
+ 'uuid' => 'subject-uuid',
+ 'public_id' => 'waypoint_subject',
+ 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'],
+ ];
+
+ // The url printed by current labels, from any host, and the Code 128 barcode.
+ foreach ([
+ 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1',
+ 'https://old-host.example/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1',
+ 'ACM1234567890SG',
+ ] as $code) {
+ $controller = new FleetOpsApiOrderCrudControllerProbe();
+ $controller->order = new FleetOpsApiOrderCrudFake();
+ $controller->resolvedSubject = $subject;
+
+ expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject')['resource'])->toBe('proof')
+ ->and($controller->createdProofs[0]['subject_uuid'])->toBe('subject-uuid');
+ }
+
+ // A scanner's whole result object, rather than its value, is refused as before
+ // instead of failing the request.
+ $controller = new FleetOpsApiOrderCrudControllerProbe();
+ $controller->order = new FleetOpsApiOrderCrudFake();
+ $controller->resolvedSubject = $subject;
+
+ expect($controller->captureQrScan(new Request(['code' => ['type' => 'qr', 'value' => 'ACM1234567890SG']]), 'order-public', 'waypoint_subject'))->toBe([
+ 'apiError' => 'Unable to validate QR code data.',
+ 'status' => 400,
+ ]);
+
+ // Another parcel's label is refused, whether it names another owner or, naming none,
+ // another tracking number.
+ foreach ([
+ 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_other&v=1',
+ 'https://console.fleetbase.test/track-order?order=ACM0000000000SG&v=1',
+ ] as $code) {
+ $controller = new FleetOpsApiOrderCrudControllerProbe();
+ $controller->order = new FleetOpsApiOrderCrudFake();
+ $controller->resolvedSubject = $subject;
+
+ expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject'))->toBe([
+ 'apiError' => 'Unable to validate QR code data.',
+ 'status' => 400,
+ ])->and($controller->createdProofs)->toBe([]);
+ }
+});
diff --git a/server/tests/ApiTrackingNumberControllerContractsTest.php b/server/tests/ApiTrackingNumberControllerContractsTest.php
index 17a21e1a3..83b3e66bc 100644
--- a/server/tests/ApiTrackingNumberControllerContractsTest.php
+++ b/server/tests/ApiTrackingNumberControllerContractsTest.php
@@ -68,9 +68,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber)
return ['resource' => 'deleted-tracking-number', 'trackingNumber' => $trackingNumber];
}
- protected function findQrModel(array $tables, array $where)
+ protected function findQrModel(string $code)
{
- $this->qrLookups[] = [$tables, $where];
+ $this->qrLookups[] = $code;
return $this->qrModel;
}
@@ -206,15 +206,12 @@ function fleetopsDecodeTrackingNumberQrRequest(array $input): DecodeTrackingNumb
$controller = new FleetOpsApiTrackingNumberControllerProbe();
$controller->qrModel = $model;
- $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => 'order-uuid']));
+ $code = 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_public&v=1';
+ $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => $code]));
+ // The raw scanned value goes to the resolver, which understands every format.
expect($response)->toBe(['resource' => 'qr-model', 'model' => $model])
- ->and($controller->qrLookups)->toBe([
- [
- ['entities', 'orders'],
- ['uuid' => 'order-uuid'],
- ],
- ]);
+ ->and($controller->qrLookups)->toBe([$code]);
$controller = new FleetOpsApiTrackingNumberControllerProbe();
diff --git a/server/tests/CompactResourceSerializationTest.php b/server/tests/CompactResourceSerializationTest.php
index 2f628407d..fb1f87925 100644
--- a/server/tests/CompactResourceSerializationTest.php
+++ b/server/tests/CompactResourceSerializationTest.php
@@ -2739,8 +2739,8 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded =
'scheduled_at' => '2026-07-30 09:00:00',
]);
- // The decoded QR content is a debug-only field and must never ride along on a webhook,
- // which is a separate literal payload rather than a filtered toArray().
+ // The webhook is a separate literal payload rather than a filtered toArray(), and its
+ // shape is unchanged: the QR content is not added to it.
expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse();
expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([
@@ -2790,14 +2790,15 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded =
]);
});
-test('tracking number resource publishes the qr code content only in debug mode', function () {
- // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a
- // scanned code match on that uuid. Debug builds publish the value beside the image so
- // an automated client can follow the flow without decoding a PNG; production must not.
+test('tracking number resource publishes a scannable qr code content beside the image', function () {
+ // qr_code is a base64 PNG; qr_code_content is the text current labels encode in it,
+ // a tracking url naming the tracking number and the subject's public id. Both are in
+ // the resource already, so it is published in every environment.
$trackingNumber = fleetopsCompactResourceFixture([
- 'tracking_number' => 'TN-DEBUG',
- 'owner_uuid' => 'owner-uuid-under-the-qr',
+ 'tracking_number' => 'TN-SCAN',
+ 'owner_uuid' => 'owner-uuid-never-published',
'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
+ 'owner' => (object) ['public_id' => 'order_public'],
'region' => 'sg',
'qr_code' => 'qr-data',
'barcode' => 'barcode-data',
@@ -2811,70 +2812,31 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded =
// cover the index resource and the webhook payload.
$previousContainer = Illuminate\Container\Container::getInstance();
$container = new class extends Illuminate\Container\Container {
- public bool $debugMode = false;
-
public function environment(...$environments)
{
return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false;
}
-
- // Stands in for the framework's Application::hasDebugModeEnabled().
- public function hasDebugModeEnabled()
- {
- return $this->debugMode;
- }
};
$container->instance('config', new Illuminate\Config\Repository([
'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]],
]));
Illuminate\Container\Container::setInstance($container);
- // Drive the REAL accessor through the container rather than overriding it, so the
- // debug check itself is exercised and not just the resource's use of it.
- $container->debugMode = true;
- $withDebug = new TrackingNumberResource($trackingNumber);
-
try {
- $debugPayload = $withDebug->resolve($request);
-
- $container->debugMode = false;
- $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request);
+ $payload = (new TrackingNumberResource($trackingNumber))->resolve($request);
+ $withoutNumber = (new TrackingNumberResource(fleetopsCompactResourceFixture([
+ 'tracking_number' => null,
+ 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
+ 'owner' => null,
+ ])))->resolve($request);
} finally {
Illuminate\Container\Container::setInstance($previousContainer);
}
- // Present and exactly the value the QR was generated from — not a public id.
- expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr')
- ->and($debugPayload['qr_code'])->toBe('qr-data')
- // Absent, not null: `when()` drops the key entirely, so a production response is
- // byte-identical to one from before the field existed.
- ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse()
- ->and($productionPayload['qr_code'])->toBe('qr-data');
-});
-
-test('tracking number resource fails closed when the debug state cannot be determined', function () {
- // Any failure to resolve the debug state must answer false rather than defaulting to
- // exposure. The real accessor is exercised here with no usable application bound.
- $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent');
- $expose->setAccessible(true);
-
- $previous = Illuminate\Container\Container::getInstance();
-
- try {
- // A container that is not an Application has no hasDebugModeEnabled().
- Illuminate\Container\Container::setInstance(new Illuminate\Container\Container());
- expect($expose->invoke(null))->toBeFalse();
-
- // And an accessor that throws must also answer false rather than propagating —
- // a resource must not be able to fail serialization over a debug check.
- Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container {
- public function hasDebugModeEnabled()
- {
- throw new RuntimeException('debug state unavailable');
- }
- });
- expect($expose->invoke(null))->toBeFalse();
- } finally {
- Illuminate\Container\Container::setInstance($previous);
- }
+ expect($payload['qr_code_content'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN&r=order_public&v=1')
+ ->and($payload['qr_code_content'])->not->toContain('owner-uuid')
+ ->and($payload['url'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN')
+ ->and($payload['qr_code'])->toBe('qr-data')
+ // Nothing to scan without a tracking number: the key is absent, not null.
+ ->and(array_key_exists('qr_code_content', $withoutNumber))->toBeFalse();
});
diff --git a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php
index 80baaba8a..7b94621e9 100644
--- a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php
+++ b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php
@@ -133,28 +133,63 @@ public function __call($method, $arguments)
->and($helper('deletedTrackingNumberResource', $found))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\DeletedResource::class)
->and($helper('jsonResponse', ['ok' => true], 200))->toBeInstanceOf(Illuminate\Http\JsonResponse::class);
- // Qr model resolution finds models and wraps them in fleet-ops v1 resources
- $qrModel = $helper('findQrModel', ['entities'], ['public_id' => 'entity_tnhelper1']);
- expect($qrModel)->not->toBeNull()
- ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class);
-
- // A table with no eloquent mapping resolves to null rather than a raw row.
- //
- // The raw-row fallback was removed: it passed the ARRAY of table names to
- // DB::table(), which stringified it to the literal table "Array" and threw
- // SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the
- // caller already handles. It could not have helped either way — qrModelResource()
- // serializes through a typed resource keyed on the model class, and a raw stdClass
- // row has none.
- $connection->table('tracking_statuses')->insert([
- 'uuid' => '44444444-4444-4444-8444-444444444403',
- 'public_id' => 'tracking_status_tnhelper1',
- 'code' => 'DISPATCHED',
- 'status' => 'Dispatched',
+ // Qr model resolution goes through the shared resolver and wraps the owner in its
+ // fleet-ops v1 resource.
+ $connection->table('tracking_numbers')->insert([
+ 'uuid' => '44444444-4444-4444-8444-444444444404',
+ 'public_id' => 'track_tnhelper2',
+ 'company_uuid' => 'company-1',
+ 'tracking_number' => 'FLB-HELPER-2',
+ 'owner_uuid' => '44444444-4444-4444-8444-444444444402',
+ 'owner_type' => Fleetbase\FleetOps\Models\Entity::class,
+ ]);
+
+ $qrModel = $helper('findQrModel', 'https://console.fleetbase.test/track-order?order=FLB-HELPER-2&r=entity_tnhelper1&v=1');
+ expect($qrModel)->toBeInstanceOf(Fleetbase\FleetOps\Models\Entity::class)
+ ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class)
+ ->and($helper('findQrModel', '{{qr_code}}'))->toBeNull();
+});
+
+test('tracking number owners resolve from every code format within the company', function () {
+ $connection = fleetopsTrackingNumberHelpersBoot();
+ $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555501', 'public_id' => 'order_codes1', 'company_uuid' => 'company-1', 'status' => 'created']);
+ $connection->table('entities')->insert(['uuid' => '55555555-5555-4555-8555-555555555502', 'public_id' => 'entity_codes1', 'company_uuid' => 'company-1', 'name' => 'Parcel']);
+ $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555503', 'public_id' => 'order_codes2', 'company_uuid' => 'company-2', 'status' => 'created']);
+ $connection->table('tracking_numbers')->insert([
+ ['uuid' => 'tn-codes-1', 'public_id' => 'track_codes1', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM1111111111SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555501', 'owner_type' => Fleetbase\FleetOps\Models\Order::class],
+ // Entity::insertGetUuid() inserts raw, storing the owner class with a leading slash.
+ ['uuid' => 'tn-codes-2', 'public_id' => 'track_codes2', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM2222222222SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555502', 'owner_type' => '\\' . Fleetbase\FleetOps\Models\Entity::class],
+ ['uuid' => 'tn-codes-3', 'public_id' => 'track_codes3', 'company_uuid' => 'company-2', 'tracking_number' => 'OTH3333333333SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555503', 'owner_type' => Fleetbase\FleetOps\Models\Order::class],
]);
- expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull()
- // And a mapped table that simply matches nothing is also null, which is the path
- // an unknown or unresolved code takes.
- ->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull();
+ $owner = fn (?string $code, ?string $company = 'company-1') => TrackingNumber::findOwnerByCode($code, $company)?->public_id;
+
+ // Labels in circulation carry the owner's bare uuid.
+ expect($owner('55555555-5555-4555-8555-555555555501'))->toBe('order_codes1')
+ // Current labels carry a tracking url; the host is irrelevant.
+ ->and($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=order_codes1&v=1'))->toBe('order_codes1')
+ ->and($owner('https://old-host.example/track-order?order=ACM2222222222SG&v=1'))->toBe('entity_codes1')
+ // The Code 128 barcode, or a value typed by hand: a tracking number or public id.
+ ->and($owner('ACM2222222222SG'))->toBe('entity_codes1')
+ ->and($owner('entity_codes1'))->toBe('entity_codes1')
+ ->and($owner('https://console.fleetbase.test/track-order?r=order_codes1'))->toBe('order_codes1');
+
+ // A url whose two identifiers disagree, or that names an unknown tracking number, is
+ // refused rather than resolved through the half that matches.
+ expect($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=entity_codes1&v=1'))->toBeNull()
+ ->and($owner('https://console.fleetbase.test/track-order?order=ACM9999999999SG&r=order_codes1&v=1'))->toBeNull()
+ // Unknown values, unmapped prefixes, and values that are no public id at all.
+ ->and($owner('55555555-5555-4555-8555-555555555599'))->toBeNull()
+ ->and($owner('ACM9999999999SG'))->toBeNull()
+ ->and($owner('vehicle_codes1'))->toBeNull()
+ ->and($owner('order_missing'))->toBeNull()
+ ->and($owner(''))->toBeNull();
+
+ // Another company's parcel never resolves, in any format, and without a company
+ // nothing does.
+ expect($owner('55555555-5555-4555-8555-555555555503'))->toBeNull()
+ ->and($owner('OTH3333333333SG'))->toBeNull()
+ ->and($owner('order_codes2'))->toBeNull()
+ ->and($owner('order_codes2', 'company-2'))->toBe('order_codes2')
+ ->and($owner('55555555-5555-4555-8555-555555555501', null))->toBeNull();
});
diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php
index 0a15bf65c..b3f7e8f5b 100644
--- a/server/tests/ObserverContractsTest.php
+++ b/server/tests/ObserverContractsTest.php
@@ -397,11 +397,16 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin
return 'TN-' . $trackingNumber->region;
}
- protected function generateBarcode(string $ownerUuid, string $type): string
+ protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array
{
- $this->barcodes[] = [$ownerUuid, $type];
+ $this->barcodes[] = [$trackingNumber, $ownerPublicId];
- return $type . '-png';
+ return ['qr_code' => 'qr-png', 'barcode' => 'barcode-png'];
+ }
+
+ protected function ownerPublicId(TrackingNumber $trackingNumber): ?string
+ {
+ return $trackingNumber->owner_uuid ? 'order_public' : null;
}
protected function createTrackingStatus(array $attributes): TrackingStatus
@@ -1292,11 +1297,12 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order
$observer->created($trackingNumber);
expect($trackingNumber->tracking_number)->toBe('TN-sg')
- ->and($trackingNumber->qr_code)->toBe('QRCODE-png')
- ->and($trackingNumber->barcode)->toBe('PDF417-png')
+ ->and($trackingNumber->qr_code)->toBe('qr-png')
+ ->and($trackingNumber->barcode)->toBe('barcode-png')
+ // Codes are generated from the new tracking number and the owner's public id,
+ // never from the owner's uuid.
->and($observer->barcodes)->toBe([
- ['owner-uuid', 'QRCODE'],
- ['owner-uuid', 'PDF417'],
+ ['TN-sg', 'order_public'],
])
->and($observer->statuses[0])->toMatchArray([
'company_uuid' => 'company-uuid',
@@ -1309,6 +1315,58 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order
->and($trackingNumber->ownerStatuses)->toHaveCount(1);
});
+test('tracking number observer reads the owner public id and renders codes through the model', function () {
+ $observer = new TrackingNumberObserver();
+ $owner = new ReflectionMethod($observer, 'ownerPublicId');
+ $images = new ReflectionMethod($observer, 'generateCodeImages');
+ $owner->setAccessible(true);
+ $images->setAccessible(true);
+
+ // No owner: nothing to look up.
+ expect($owner->invoke($observer, new TrackingNumber()))->toBeNull();
+
+ $order = new Order();
+ $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => 'order_public'], true);
+ $trackingNumber = new TrackingNumber();
+ $trackingNumber->setRawAttributes(['owner_uuid' => 'owner-uuid', 'owner_type' => Order::class], true);
+ $trackingNumber->setRelation('owner', $order);
+
+ expect($owner->invoke($observer, $trackingNumber))->toBe('order_public');
+
+ // An owner without a public id yields none rather than an empty `r` parameter.
+ $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => ''], true);
+ expect($owner->invoke($observer, $trackingNumber))->toBeNull();
+
+ $previousConsole = config('fleetbase.console');
+ config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'secure' => true]]);
+ foreach (['DNS2D', 'DNS1D'] as $facade) {
+ app()->instance($facade, new class($facade) {
+ public function __construct(private string $facade)
+ {
+ }
+
+ public function getBarcodePNG($code, $type, ...$size)
+ {
+ return $this->facade . ':' . $type . ':' . $code;
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance($facade);
+ }
+
+ try {
+ expect($images->invoke($observer, 'TN-sg', 'order_public'))->toBe([
+ 'qr_code' => 'DNS2D:QRCODE,M:https://console.fleetbase.test/track-order?order=TN-sg&r=order_public&v=1',
+ 'barcode' => 'DNS1D:C128:TN-sg',
+ ]);
+ } finally {
+ config(['fleetbase.console' => $previousConsole]);
+ foreach (['DNS2D', 'DNS1D'] as $facade) {
+ app()->forgetInstance($facade);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance($facade);
+ }
+ }
+});
+
test('zone observer invalidates service area cache for lifecycle events and original service area', function () {
$zone = new Zone();
$zone->setRawAttributes([
diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php
index 6bef10f36..3587e69ef 100644
--- a/server/tests/Unit/Models/TrackingNumberTest.php
+++ b/server/tests/Unit/Models/TrackingNumberTest.php
@@ -84,13 +84,21 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b
beforeEach(function () {
FleetOpsTrackingNumberInsertFake::resetInsertFake();
+
+ $this->previousConsoleConfig = config('fleetbase.console');
+ config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]);
+});
+
+afterEach(function () {
+ config(['fleetbase.console' => $this->previousConsoleConfig]);
});
test('tracking number insert filters values and creates initial owner status', function () {
$owner = new Order();
$owner->setRawAttributes([
- 'uuid' => 'order-uuid',
- 'status' => 'pending',
+ 'uuid' => 'order-uuid',
+ 'public_id' => 'order_public',
+ 'status' => 'pending',
], true);
$uuid = FleetOpsTrackingNumberInsertFake::insertGetUuid([
@@ -114,8 +122,10 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b
'owner_uuid' => 'order-uuid',
'owner_type' => Utils::getMutationType($owner),
'tracking_number' => 'TRACK-AE',
- 'qr_code' => 'QRCODE:order-uuid',
- 'barcode' => 'PDF417:order-uuid',
+ // The QR carries the tracking url naming the owner by public id, never its uuid;
+ // the barcode carries the bare tracking number printed beneath it.
+ 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-AE&r=order_public&v=1',
+ 'barcode' => 'C128:TRACK-AE',
'meta' => '{"source":"api"}',
])->and($inserted)->not->toHaveKey('not_allowed');
@@ -147,8 +157,8 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b
->and(FleetOpsTrackingNumberInsertFake::$insertedValues[0])->toMatchArray([
'uuid' => 'tracking-uuid',
'tracking_number' => 'TRACK-SG',
- 'qr_code' => 'QRCODE:',
- 'barcode' => 'PDF417:',
+ 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&v=1',
+ 'barcode' => 'C128:TRACK-SG',
])
->and(FleetOpsTrackingNumberInsertFake::$createdStatuses)->toBe([])
->and(FleetOpsTrackingNumberInsertFake::$statusUpdates)->toBe([])
@@ -197,3 +207,54 @@ public function __call($method, $arguments)
->and(TrackingNumber::findTrackingOrFail('track_findone1')->uuid)->toBe('tn-find-1')
->and(fn () => TrackingNumber::findTrackingOrFail('missing-number'))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class);
});
+
+test('tracking number insert takes the owner public id from callers without an owner model', function () {
+ // Waypoints and entities insert their owner with a raw query and pass only ids.
+ FleetOpsTrackingNumberInsertFake::insertGetUuid([
+ 'owner_uuid' => 'waypoint-uuid',
+ 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Waypoint',
+ 'owner_public_id' => 'waypoint_public',
+ 'region' => 'SG',
+ ]);
+
+ $inserted = FleetOpsTrackingNumberInsertFake::$insertedValues[0];
+
+ expect($inserted['qr_code'])->toBe('QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&r=waypoint_public&v=1')
+ // A helper key, not a column: the fillable filter keeps it out of the insert.
+ ->and($inserted)->not->toHaveKey('owner_public_id')
+ ->and($inserted['owner_uuid'])->toBe('waypoint-uuid');
+});
+
+test('tracking number code images render a medium-correction QR and a Code 128 barcode', function () {
+ $calls = new ArrayObject();
+ foreach (['DNS2D', 'DNS1D'] as $facade) {
+ app()->instance($facade, new class($facade, $calls) {
+ public function __construct(private string $facade, private ArrayObject $calls)
+ {
+ }
+
+ public function getBarcodePNG(...$arguments)
+ {
+ $this->calls[] = array_merge([$this->facade], $arguments);
+
+ return $this->facade . '-png';
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance($facade);
+ }
+
+ try {
+ expect(TrackingNumber::codeImages('ACM1234567890SG', 'entity_public'))->toBe([
+ 'qr_code' => 'DNS2D-png',
+ 'barcode' => 'DNS1D-png',
+ ])->and($calls->getArrayCopy())->toBe([
+ ['DNS2D', 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=entity_public&v=1', 'QRCODE,M'],
+ ['DNS1D', 'ACM1234567890SG', 'C128', 2, 60],
+ ]);
+ } finally {
+ foreach (['DNS2D', 'DNS1D'] as $facade) {
+ app()->forgetInstance($facade);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance($facade);
+ }
+ }
+});
diff --git a/server/tests/Unit/Models/WaypointTest.php b/server/tests/Unit/Models/WaypointTest.php
index 0ee6722db..ec97ac515 100644
--- a/server/tests/Unit/Models/WaypointTest.php
+++ b/server/tests/Unit/Models/WaypointTest.php
@@ -218,9 +218,10 @@ public static function with($relations)
])->and($inserted)->not->toHaveKey('not_allowed')
->and(FleetOpsWaypointInsertFake::$createdTracking)->toHaveCount(1)
->and(FleetOpsWaypointInsertFake::$createdTracking[0])->toMatchArray([
- 'owner_uuid' => 'waypoint-uuid',
- 'owner_type' => Utils::getModelClassName('waypoint'),
- 'region' => 'AE',
+ 'owner_uuid' => 'waypoint-uuid',
+ 'owner_type' => Utils::getModelClassName('waypoint'),
+ 'owner_public_id' => FleetOpsWaypointInsertFake::$insertedValues[0]['public_id'],
+ 'region' => 'AE',
])
->and(FleetOpsWaypointInsertFake::$createdTracking[0]['location'])->toBe('POINT(55.2708 25.2048)')
->and(FleetOpsWaypointInsertFake::$trackingNumberWrites)->toBe([
diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php
new file mode 100644
index 000000000..deef45224
--- /dev/null
+++ b/server/tests/Unit/Support/TrackingCodeTest.php
@@ -0,0 +1,135 @@
+previousConsoleConfig = config('fleetbase.console');
+ config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]);
+});
+
+afterEach(function () {
+ config(['fleetbase.console' => $this->previousConsoleConfig]);
+});
+
+test('qr content is a versioned tracking url naming the owner by public id', function () {
+ expect(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d'))
+ ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_a1b2c3d&v=1')
+ // No owner: the tracking number alone still resolves.
+ ->and(TrackingCode::qrContent('ACM1234567890SG'))
+ ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&v=1')
+ // Tracking numbers start with the company name, which may hold spaces.
+ ->and(TrackingCode::qrContent('A B1234567890SG', ''))
+ ->toBe('https://console.fleetbase.test/track-order?order=A+B1234567890SG&v=1');
+});
+
+test('qr content follows the configured console origin but never the app environment', function () {
+ config(['fleetbase.console' => ['host' => 'fleetbase.test', 'subdomain' => 'console', 'secure' => false]]);
+ expect(TrackingCode::qrContent('TN1', 'order_x'))->toBe('http://console.fleetbase.test/track-order?order=TN1&r=order_x&v=1');
+
+ config(['fleetbase.console' => ['host' => 'https://tracking.example.test/', 'subdomain' => 'ignored']]);
+ expect(TrackingCode::qrContent('TN1'))->toBe('https://tracking.example.test/track-order?order=TN1&v=1');
+
+ // Unset `secure` defaults to https, whatever environment generated the label.
+ config(['fleetbase.console' => ['host' => 'console.fleetbase.test']]);
+ expect(TrackingCode::qrContent('TN1'))->toStartWith('https://console.fleetbase.test/');
+});
+
+test('barcode content is the bare tracking number printed beneath it', function () {
+ expect(TrackingCode::barcodeContent('ACM1234567890SG'))->toBe('ACM1234567890SG');
+});
+
+test('parse reads every code format in circulation', function () {
+ $empty = ['uuid' => null, 'tracking_number' => null, 'public_id' => null, 'reference' => null, 'version' => null];
+
+ // Generated codes round-trip.
+ expect(TrackingCode::parse(TrackingCode::qrContent('ACM1234567890SG', 'waypoint_a1b2c3d')))->toBe(array_merge($empty, [
+ 'tracking_number' => 'ACM1234567890SG',
+ 'public_id' => 'waypoint_a1b2c3d',
+ 'version' => 1,
+ ]))
+ // Legacy labels encode the owner's bare uuid.
+ ->and(TrackingCode::parse(' 6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60 '))->toBe(array_merge($empty, [
+ 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60',
+ ]))
+ // A barcode, or a value typed by hand, may be a tracking number or a public id.
+ ->and(TrackingCode::parse('ACM1234567890SG'))->toBe(array_merge($empty, ['reference' => 'ACM1234567890SG']))
+ ->and(TrackingCode::parse(''))->toBe($empty)
+ ->and(TrackingCode::parse(null))->toBe($empty);
+});
+
+test('parse ignores host and path and tolerates hostile or partial urls', function () {
+ // Any host, a `tn` alias, and a tracking number carried in a deeper path.
+ expect(TrackingCode::parse('HTTPS://old-domain.example/track-order?tn=TN9&r=entity_e1&v=2'))->toMatchArray([
+ 'tracking_number' => 'TN9',
+ 'public_id' => 'entity_e1',
+ 'version' => 2,
+ ])
+ ->and(TrackingCode::parse('https://console.example/~/track/TN%2042?r=order_o1'))->toMatchArray([
+ 'tracking_number' => 'TN 42',
+ 'public_id' => 'order_o1',
+ 'version' => null,
+ ])
+ // The tracking page's own path is not mistaken for a tracking number.
+ ->and(TrackingCode::parse('https://console.example/track-order?r=order_o1'))->toMatchArray([
+ 'tracking_number' => null,
+ 'public_id' => 'order_o1',
+ ])
+ // Array or blank parameters and a non-numeric version are dropped, not trusted.
+ ->and(TrackingCode::parse('https://console.example/track-order?order[]=a&r=%20&v=1x'))->toMatchArray([
+ 'tracking_number' => null,
+ 'public_id' => null,
+ 'version' => null,
+ ])
+ // parse_url() rejects this outright.
+ ->and(TrackingCode::parse('http:///track-order'))->toMatchArray([
+ 'tracking_number' => null,
+ 'public_id' => null,
+ 'version' => null,
+ ]);
+});
+
+test('matches accepts legacy and new codes for the scanned subject only', function () {
+ $order = (object) [
+ 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60',
+ 'public_id' => 'order_a1b2c3d',
+ 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'],
+ ];
+
+ expect(TrackingCode::matches('6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', $order))->toBeTrue()
+ ->and(TrackingCode::matches('00000000-0000-4000-8000-000000000000', $order))->toBeFalse()
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d'), $order))->toBeTrue()
+ // Handhelds may not preserve case.
+ ->and(TrackingCode::matches('acm1234567890sg', $order))->toBeTrue()
+ ->and(TrackingCode::matches('order_a1b2c3d', $order))->toBeTrue()
+ ->and(TrackingCode::matches('order_other', $order))->toBeFalse()
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $order))->toBeTrue()
+ ->and(TrackingCode::matches('https://console.example/track-order?r=order_a1b2c3d', $order))->toBeTrue()
+ // The owner named in the code decides: another owner is refused even with this
+ // subject's tracking number, while a second tracking number issued for this
+ // subject still names it and matches.
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_other'), $order))->toBeFalse()
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG', 'order_a1b2c3d'), $order))->toBeTrue()
+ // Without an owner, the number must be this subject's.
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG'), $order))->toBeFalse()
+ // A URL that names nothing matches nothing.
+ ->and(TrackingCode::matches('https://console.example/track-order', $order))->toBeFalse()
+ ->and(TrackingCode::matches('', $order))->toBeFalse()
+ // A request may carry a scanner's whole result object instead of its value.
+ ->and(TrackingCode::matches(['type' => 'qr', 'value' => 'order_a1b2c3d'], $order))->toBeFalse()
+ ->and(TrackingCode::matches(null, $order))->toBeFalse()
+ ->and(TrackingCode::matches('order_a1b2c3d', null))->toBeFalse()
+ ->and(TrackingCode::matches('order_a1b2c3d', (object) ['public_id' => 'order_a1b2c3d']))->toBeFalse();
+});
+
+test('matches decides a subject without its own tracking number by public id', function () {
+ // A service stop resolves to a place, which has no tracking number of its own.
+ $place = (object) ['uuid' => 'place-uuid', 'public_id' => 'place_p1'];
+
+ expect(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'place_p1'), $place))->toBeTrue()
+ ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $place))->toBeFalse()
+ ->and(TrackingCode::matches('ACM1234567890SG', $place))->toBeFalse()
+ ->and(TrackingCode::matches('place_p1', $place))->toBeTrue()
+ // A tracking number may also sit directly on the subject.
+ ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => '', 'tracking_number' => 'TN-FLAT']))->toBeTrue()
+ ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => null]))->toBeFalse();
+});