diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml new file mode 100644 index 00000000..e7ab6fbd --- /dev/null +++ b/.github/workflows/postman.yml @@ -0,0 +1,25 @@ +name: API Contract (Postman) + +# Boots a full Fleetbase stack (published image) and runs the Storefront API +# Postman collection against the live API. Delegates to the reusable workflow in +# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN +# (inherited); no-ops until POSTMAN_API_KEY is set. +# TODO: change @dev-v0.7.53 to @main once that branch is merged. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + with: + collections: "Fleetbase Storefront API" + build-from-source: false + secrets: inherit diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 978806a1..3834fa8d 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -52,3 +52,12 @@ jobs: name: storefront-coverage-clover path: coverage/clover.xml if-no-files-found: error + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/clover.xml + disable_search: true + flags: backend + fail_ci_if_error: false diff --git a/README.md b/README.md index 4b26c88b..bee60dd4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@
+
diff --git a/addon/controllers/customers/index.js b/addon/controllers/customers/index.js
index d508964d..70e59bf5 100644
--- a/addon/controllers/customers/index.js
+++ b/addon/controllers/customers/index.js
@@ -232,7 +232,6 @@ export default class CustomersIndexController extends BaseController {
},
{
label: this.intl.t('storefront.customers.index.edit-customer'),
-
// fn: this.editVendor,
},
{
diff --git a/addon/routes/application.js b/addon/routes/application.js
index 5aca0081..13505b5e 100644
--- a/addon/routes/application.js
+++ b/addon/routes/application.js
@@ -50,7 +50,8 @@ export default class ApplicationRoute extends Route {
controller.loadProductCategories();
}
- afterModel() {
+ afterModel(stores) {
+ this.storefront.synchronizeActiveStore(stores);
this.storefront.listenForIncomingOrders();
}
diff --git a/addon/services/storefront-dashboard.js b/addon/services/storefront-dashboard.js
index 1a7075a9..df16655a 100644
--- a/addon/services/storefront-dashboard.js
+++ b/addon/services/storefront-dashboard.js
@@ -17,8 +17,6 @@ export default class StorefrontDashboardService extends Service.extend(Evented)
@tracked datePickerValue = '';
@tracked datePickerButtons = [];
- isSelectingPreset = false;
-
constructor() {
super(...arguments);
const [startDate, endDate] = getDateRangeByLabel(DEFAULT_RANGE_LABEL);
@@ -36,17 +34,7 @@ export default class StorefrontDashboardService extends Service.extend(Evented)
createDatePickerButtons() {
return createDateRangeButtons((range) => {
this.setRange(range.startDate, range.endDate, range.label);
- }).map((button) => ({
- ...button,
- onClick: (datepicker) => {
- this.isSelectingPreset = true;
- try {
- button.onClick(datepicker);
- } finally {
- this.isSelectingPreset = false;
- }
- },
- }));
+ });
}
withStore(store) {
@@ -57,10 +45,6 @@ export default class StorefrontDashboardService extends Service.extend(Evented)
}
@action selectDates({ date, formattedDate }) {
- if (this.isSelectingPreset) {
- return;
- }
-
if (!formattedDate) {
return;
}
diff --git a/addon/services/storefront.js b/addon/services/storefront.js
index 9f2f8ea3..3ce5d258 100644
--- a/addon/services/storefront.js
+++ b/addon/services/storefront.js
@@ -27,10 +27,12 @@ export default class StorefrontService extends Service.extend(Evented) {
/**
* Gets the active store.
- * @returns {Object} The active store object.
+ * @returns {Object|null} The active store object.
*/
get activeStore() {
- return this.findActiveStore();
+ const activeStoreId = this.activeStoreId ?? this.currentUser.getOption('activeStorefront');
+
+ return activeStoreId ? this.store.peekRecord('store', activeStoreId) : null;
}
/**
@@ -65,29 +67,27 @@ export default class StorefrontService extends Service.extend(Evented) {
* @returns {Object|null} The active store object or null if not found.
*/
findActiveStore() {
- const activeStoreId = this.activeStoreId ?? this.currentUser.getOption('activeStorefront');
-
- if (!activeStoreId) {
- const stores = this.store.peekAll('store');
-
- if (stores.firstObject) {
- this.currentUser.setOption('activeStorefront', stores.firstObject.id);
- this.activeStoreId = stores.firstObject.id;
- }
-
- return stores.firstObject;
- }
-
- const activeStore = this.store.peekRecord('store', activeStoreId);
+ return this.activeStore;
+ }
- if (!activeStore) {
- this.currentUser.setOption('activeStorefront', undefined);
- this.activeStoreId = undefined;
+ /**
+ * Synchronizes active storefront state after the store collection has loaded.
+ * All tracked writes happen here instead of inside render-time getters.
+ *
+ * @param {Array|Object|null} stores loaded store collection
+ * @returns {Object|null} the selected store
+ */
+ synchronizeActiveStore(stores = this.store.peekAll('store')) {
+ const activeStoreId = this.activeStoreId ?? this.currentUser.getOption('activeStorefront');
+ const activeStore = activeStoreId ? this.store.peekRecord('store', activeStoreId) : null;
+ const firstStore = stores?.firstObject ?? Array.from(stores ?? [])[0] ?? null;
+ const nextStore = activeStore ?? firstStore;
+ const nextStoreId = nextStore?.id;
- return this.findActiveStore();
- }
+ this.currentUser.setOption('activeStorefront', nextStoreId);
+ this.activeStoreId = nextStoreId;
- return activeStore;
+ return nextStore;
}
/**
diff --git a/addon/utils/commerce-date-ranges.js b/addon/utils/commerce-date-ranges.js
index 37ec2821..ec9546d7 100644
--- a/addon/utils/commerce-date-ranges.js
+++ b/addon/utils/commerce-date-ranges.js
@@ -149,8 +149,7 @@ export function createDateRangeButtons(onRangeSelect) {
className: 'custom-date-range-btn',
onClick: (datepicker) => {
const [startDate, endDate] = range.getValue();
- datepicker.selectDate([startDate, endDate]);
- datepicker.hide();
+ datepicker.selectDate([startDate, endDate], { silent: true });
// Call the callback if provided
if (typeof onRangeSelect === 'function') {
@@ -161,6 +160,8 @@ export function createDateRangeButtons(onRangeSelect) {
formattedRange: `${format(startDate, 'MMM dd, yyyy')} - ${format(endDate, 'MMM dd, yyyy')}`,
});
}
+
+ datepicker.hide();
},
}));
}
diff --git a/scripts/coverage-summary.php b/scripts/coverage-summary.php
index 09cda878..caf6b2c2 100644
--- a/scripts/coverage-summary.php
+++ b/scripts/coverage-summary.php
@@ -38,6 +38,27 @@ function intMetric(SimpleXMLElement $node, string $name): int
$files = [];
$directories = [];
+$classNodes = $project->xpath('.//class') ?: [];
+
+if ($classNodes !== []) {
+ $classes = 0;
+ $coveredClasses = 0;
+
+ foreach ($classNodes as $classNode) {
+ $classStatements = intMetric($classNode, 'statements');
+ $coveredClassStatements = intMetric($classNode, 'coveredstatements');
+
+ if ($classStatements === 0) {
+ continue;
+ }
+
+ $classes++;
+
+ if ($coveredClassStatements === $classStatements) {
+ $coveredClasses++;
+ }
+ }
+}
foreach ($project->xpath('.//file') ?: [] as $file) {
$path = (string) $file['name'];
diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php
index 7a2f17f7..888d046b 100644
--- a/scripts/pest-bootstrap.php
+++ b/scripts/pest-bootstrap.php
@@ -14,15 +14,50 @@
}
}
+if (!class_exists('PhpOption\Option')) {
+ eval('namespace PhpOption; class Option { public function __construct(private mixed $value) {} public static function fromValue(mixed $value): self { return new self($value); } public function map(callable $callback): self { return $this->value === null ? $this : new self($callback($this->value)); } public function getOrCall(callable $default): mixed { return $this->value ?? $default(); } public function getOrThrow(\Throwable $exception): mixed { if ($this->value === null) { throw $exception; } return $this->value; } }');
+}
+
if (!function_exists('config')) {
- function config(?string $key = null, mixed $default = null): mixed
+ function config(array|string|null $key = null, mixed $default = null): mixed
{
- return $default;
+ static $values = [];
+
+ if (is_array($key)) {
+ foreach ($key as $configKey => $value) {
+ Illuminate\Support\Arr::set($values, $configKey, $value);
+ }
+
+ return null;
+ }
+
+ if ($key === null) {
+ return $values;
+ }
+
+ return Illuminate\Support\Arr::get($values, $key, $default);
+ }
+}
+
+if (!function_exists('logger')) {
+ function logger(): mixed
+ {
+ return Illuminate\Container\Container::getInstance()->make('log');
}
}
if (class_exists('Illuminate\Container\Container') && class_exists('Illuminate\Support\Facades\Facade')) {
$app = Illuminate\Container\Container::getInstance();
+
+ if (get_class($app) === Illuminate\Container\Container::class) {
+ if (!class_exists('Fleetbase\TestSupport\ApplicationContainer')) {
+ eval('namespace Fleetbase\TestSupport; class ApplicationContainer extends \Illuminate\Container\Container { public function environment(...$environments): string|bool { if ($environments === []) { return "testing"; } return in_array("testing", $environments, true); } public function isProduction(): bool { return false; } public function hasDebugModeEnabled(): bool { return true; } }');
+ }
+
+ $app = new Fleetbase\TestSupport\ApplicationContainer();
+ Illuminate\Container\Container::setInstance($app);
+ }
+
Illuminate\Support\Facades\Facade::setFacadeApplication($app);
if (!$app->bound('http') && class_exists('Illuminate\Http\Client\Factory')) {
@@ -36,6 +71,102 @@ function config(?string $key = null, mixed $default = null): mixed
$app->singleton('log', fn () => new Fleetbase\TestSupport\LoggerManager());
}
+
+ if (
+ !$app->bound('cache')
+ && class_exists('Illuminate\Cache\Repository')
+ && class_exists('Illuminate\Cache\ArrayStore')
+ ) {
+ $app->singleton('cache', fn () => new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore()));
+ }
+
+ if (!$app->bound('hash')) {
+ if (!class_exists('Fleetbase\TestSupport\PasswordHasher')) {
+ eval('namespace Fleetbase\TestSupport; class PasswordHasher { public function check(mixed $value, mixed $hashedValue, array $options = []): bool { return is_string($value) && is_string($hashedValue) && password_verify($value, $hashedValue); } public function make(mixed $value, array $options = []): string { return password_hash((string) $value, PASSWORD_BCRYPT); } public function needsRehash(mixed $hashedValue, array $options = []): bool { return password_needs_rehash((string) $hashedValue, PASSWORD_BCRYPT); } }');
+ }
+
+ $app->singleton('hash', fn () => new Fleetbase\TestSupport\PasswordHasher());
+ }
+
+ if (!$app->bound('request') && class_exists('Illuminate\Http\Request')) {
+ $request = Illuminate\Http\Request::create('/');
+
+ if (class_exists('Illuminate\Session\Store') && class_exists('Illuminate\Session\ArraySessionHandler')) {
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'storefront-tests',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ }
+
+ $app->instance('request', $request);
+ }
+
+ if (!$app->bound('response') && class_exists('Illuminate\Http\JsonResponse')) {
+ if (!class_exists('Fleetbase\TestSupport\ResponseFactory')) {
+ eval('namespace Fleetbase\TestSupport; class ResponseFactory { public function json(mixed $data = [], int $status = 200, array $headers = [], int $options = 0): \Illuminate\Http\JsonResponse { return new \Illuminate\Http\JsonResponse($data, $status, $headers, $options); } public function error(string $message, int $status = 400): \Illuminate\Http\JsonResponse { return $this->json(["error" => $message], $status); } public function apiError(string $message, int $status = 400): \Illuminate\Http\JsonResponse { return $this->error($message, $status); } }');
+ }
+
+ $app->instance('response', new Fleetbase\TestSupport\ResponseFactory());
+ }
+
+ if (
+ !$app->bound('validator')
+ && class_exists('Illuminate\Validation\Factory')
+ && class_exists('Illuminate\Translation\Translator')
+ && class_exists('Illuminate\Translation\ArrayLoader')
+ ) {
+ $translator = new Illuminate\Translation\Translator(
+ new Illuminate\Translation\ArrayLoader(),
+ 'en'
+ );
+ $app->singleton('validator', fn () => new Illuminate\Validation\Factory($translator, $app));
+ }
+
+ if (!$app->bound('validator') && !class_exists('Illuminate\Validation\Factory')) {
+ if (!class_exists('Fleetbase\TestSupport\ValidatorFactory')) {
+ eval('namespace Fleetbase\TestSupport; class ValidatorFactory { public function make(array $data, array $rules = [], array $messages = [], array $attributes = []): Validator { return new Validator(); } } class Validator { public function fails(): bool { return false; } public function errors(): \Illuminate\Support\MessageBag { return new \Illuminate\Support\MessageBag(); } }');
+ }
+
+ $app->singleton('validator', fn () => new Fleetbase\TestSupport\ValidatorFactory());
+ }
+}
+
+if (
+ class_exists('Illuminate\Database\Capsule\Manager')
+ && class_exists('Illuminate\Database\Eloquent\Model')
+ && Illuminate\Database\Eloquent\Model::getConnectionResolver() === null
+) {
+ $database = new Illuminate\Database\Capsule\Manager();
+
+ foreach (['mysql', 'fleetbase', 'fleetops', 'storefront'] as $connection) {
+ $database->addConnection([
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ], $connection);
+ }
+
+ $database->getDatabaseManager()->setDefaultConnection('mysql');
+ $database->setAsGlobal();
+ $database->bootEloquent();
+
+ if (class_exists('Illuminate\Container\Container')) {
+ $container = Illuminate\Container\Container::getInstance();
+ $container->instance('db', $database->getDatabaseManager());
+ $container->instance('db.schema', $database->getDatabaseManager()->connection()->getSchemaBuilder());
+ }
+}
+
+if (class_exists('Illuminate\Database\Eloquent\Builder')) {
+ Illuminate\Database\Eloquent\Builder::macro('orderByDistance', function (): mixed {
+ return $this;
+ });
+}
+
+if (class_exists('Illuminate\Database\Query\Builder')) {
+ Illuminate\Database\Query\Builder::macro('orderByDistance', function (): mixed {
+ return $this;
+ });
}
if (!function_exists('app')) {
@@ -54,9 +185,12 @@ function app(?string $abstract = null, array $parameters = []): mixed
if (!function_exists('request')) {
function request(?string $key = null, mixed $default = null): mixed
{
- $request = class_exists('Illuminate\Http\Request') ? Illuminate\Http\Request::create('/') : new stdClass();
+ $request = class_exists('Illuminate\Container\Container')
+ && Illuminate\Container\Container::getInstance()->bound('request')
+ ? Illuminate\Container\Container::getInstance()->make('request')
+ : (class_exists('Illuminate\Http\Request') ? Illuminate\Http\Request::create('/') : new stdClass());
- return $key === null ? $request : $default;
+ return $key === null || !method_exists($request, 'input') ? $request : $request->input($key, $default);
}
}
@@ -75,6 +209,52 @@ function session(array|string|null $key = null, mixed $default = null): mixed
}
}
+if (!function_exists('response')) {
+ function response(mixed $content = null, int $status = 200, array $headers = []): mixed
+ {
+ $factory = app('response');
+
+ return $content === null ? $factory : $factory->json($content, $status, $headers);
+ }
+}
+
+if (!function_exists('url')) {
+ function url(?string $path = null, array $parameters = [], ?bool $secure = null): string
+ {
+ $base = $secure === false ? 'http://localhost' : 'https://localhost';
+ $url = $path ? $base . '/' . ltrim($path, '/') : $base;
+
+ return $parameters ? $url . '?' . http_build_query($parameters) : $url;
+ }
+}
+
+if (!function_exists('dispatch')) {
+ function dispatch(mixed $job): mixed
+ {
+ if ($job instanceof Closure) {
+ return new class($job) {
+ public function __construct(private Closure $job)
+ {
+ }
+
+ public function afterCommit(): static
+ {
+ return $this;
+ }
+ };
+ }
+
+ return $job;
+ }
+}
+
+if (!function_exists('event')) {
+ function event(mixed $event): mixed
+ {
+ return $event;
+ }
+}
+
if (!function_exists('now') && class_exists('Illuminate\Support\Carbon')) {
function now($tz = null): Illuminate\Support\Carbon
{
@@ -86,6 +266,10 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Illuminate\Foundation\Auth\Access; trait AuthorizesRequests {}');
}
+if (!class_exists('Illuminate\Foundation\Auth\User') && class_exists('Illuminate\Database\Eloquent\Model')) {
+ eval('namespace Illuminate\Foundation\Auth; class User extends \Illuminate\Database\Eloquent\Model {}');
+}
+
if (!trait_exists('Illuminate\Foundation\Bus\Dispatchable')) {
eval('namespace Illuminate\Foundation\Bus; trait Dispatchable {}');
}
@@ -94,12 +278,72 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Illuminate\Foundation\Bus; trait DispatchesJobs {}');
}
+if (!trait_exists('Illuminate\Foundation\Events\Dispatchable')) {
+ eval('namespace Illuminate\Foundation\Events; trait Dispatchable {}');
+}
+
if (!trait_exists('Illuminate\Foundation\Validation\ValidatesRequests')) {
eval('namespace Illuminate\Foundation\Validation; trait ValidatesRequests {}');
}
if (!class_exists('Illuminate\Foundation\Http\FormRequest') && class_exists('Illuminate\Http\Request')) {
- eval('namespace Illuminate\Foundation\Http; class FormRequest extends \Illuminate\Http\Request { public function authorize(): bool { return true; } public function rules(): array { return []; } public function responseWithErrors(\Illuminate\Contracts\Validation\Validator $validator) { return $validator; } }');
+ eval('namespace Illuminate\Foundation\Http; class FormRequest extends \Illuminate\Http\Request { public function authorize() { return true; } public function rules() { return []; } public function responseWithErrors(\Illuminate\Contracts\Validation\Validator $validator) { return $validator; } }');
+}
+
+if (!class_exists('Illuminate\Validation\Rules\RequiredIf')) {
+ eval('namespace Illuminate\Validation\Rules; class RequiredIf { public function __construct(private mixed $condition) {} public function __toString(): string { return (bool) value($this->condition) ? "required" : ""; } }');
+}
+
+if (!class_exists('Illuminate\Validation\Rules\Unique')) {
+ eval('namespace Illuminate\Validation\Rules; class Unique { private array $callbacks = []; public function __construct(public string $table, public string $column = "NULL") {} public function where(callable $callback): self { $this->callbacks[] = $callback; return $this; } public function queryCallbacks(): array { return $this->callbacks; } public function __toString(): string { return "unique:{$this->table},{$this->column}"; } }');
+}
+
+if (!class_exists('Illuminate\Validation\Rule')) {
+ eval('namespace Illuminate\Validation; class Rule { public static function requiredIf(mixed $condition): \Illuminate\Validation\Rules\RequiredIf { return new \Illuminate\Validation\Rules\RequiredIf($condition); } public static function unique(string $table, string $column = "NULL"): \Illuminate\Validation\Rules\Unique { return new \Illuminate\Validation\Rules\Unique($table, $column); } }');
+}
+
+if (class_exists('Illuminate\Support\Arr') && !Illuminate\Support\Arr::hasMacro('insertAfterKey')) {
+ Illuminate\Support\Arr::macro('insertAfterKey', function (array $array = [], array $items = [], string|int $key = 0): array {
+ $position = array_search($key, array_keys($array), true);
+
+ if ($position === false) {
+ return $array + $items;
+ }
+
+ $position++;
+
+ return array_slice($array, 0, $position, true)
+ + $items
+ + array_slice($array, $position, null, true);
+ });
+}
+
+if (class_exists('Illuminate\Http\Request') && !Illuminate\Http\Request::hasMacro('inArray')) {
+ Illuminate\Http\Request::macro('inArray', function (string $parameter, mixed $needle): bool {
+ return in_array($needle, (array) $this->input($parameter, []), true);
+ });
+
+ Illuminate\Http\Request::macro('isArray', function (string $parameter): bool {
+ return $this->has($parameter) && is_array($this->input($parameter));
+ });
+}
+
+if (class_exists('Illuminate\Http\Request') && !Illuminate\Http\Request::hasMacro('array')) {
+ Illuminate\Http\Request::macro('array', function (string $parameter, array $default = []): array {
+ return (array) $this->input($parameter, $default);
+ });
+}
+
+if (class_exists('Illuminate\Http\Request') && !Illuminate\Http\Request::hasMacro('or')) {
+ Illuminate\Http\Request::macro('or', function (array $parameters, mixed $default = null): mixed {
+ foreach ($parameters as $parameter) {
+ if ($this->filled($parameter)) {
+ return $this->input($parameter);
+ }
+ }
+
+ return $default;
+ });
}
if (!interface_exists('Fleetbase\Ai\Contracts\AIContextCapabilityInterface')) {
@@ -114,6 +358,10 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Fleetbase\Ai\Models; class AiTask { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } }');
}
+if (!class_exists('Fleetbase\Support\SocketCluster\SocketClusterService', false)) {
+ eval('namespace Fleetbase\Support\SocketCluster; class SocketClusterService { public static array $published = []; public static function publish(string $channel, mixed $data): bool { static::$published[] = [$channel, $data]; return true; } }');
+}
+
if (!class_exists('Fleetbase\Ai\Support\Capabilities\AbstractAICapability')) {
eval('namespace Fleetbase\Ai\Support\Capabilities; abstract class AbstractAICapability {}');
}
@@ -130,6 +378,10 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Fleetbase\Ai\Support; class AiRelativeDateResolver { public function __construct($parser = null) {} public function resolveDateTime(string $prompt, ?string $timezone = null): ?\Illuminate\Support\Carbon { if (preg_match("/(\d+)\s+days?\s+from\s+now/i", $prompt, $matches)) { return \Illuminate\Support\Carbon::now($timezone)->addDays((int) $matches[1]); } return null; } public function resolveWindow(string $prompt, ?string $timezone = null): ?array { $timezone = $timezone ?: date_default_timezone_get(); $now = \Illuminate\Support\Carbon::now($timezone); if (str_contains(strtolower($prompt), "last week")) { $start = $now->copy()->subWeek()->startOfWeek(); $end = $now->copy()->subWeek()->endOfWeek(); return ["label" => "last week", "timezone" => $timezone, "start" => $start, "end" => $end]; } if (str_contains(strtolower($prompt), "yesterday")) { $start = $now->copy()->subDay()->startOfDay(); $end = $now->copy()->subDay()->endOfDay(); return ["label" => "yesterday", "timezone" => $timezone, "start" => $start, "end" => $end]; } return null; } }');
}
+if (!class_exists('Fleetbase\Support\Auth', false)) {
+ eval('namespace Fleetbase\Support; class Auth { public static mixed $user = null; public static array $permissions = []; public static function getUserFromSession(): mixed { return static::$user; } public static function can(string $permission): bool { return in_array($permission, static::$permissions, true); } public static function getDirectivesFromRequest(\Illuminate\Http\Request $request): \Illuminate\Support\Collection { return collect(); } }');
+}
+
set_error_handler(function (int $severity, string $message): bool {
if (str_contains($message, '/pestphp/pest/vendor/autoload.php')) {
return true;
diff --git a/scripts/pest-runner.php b/scripts/pest-runner.php
index dd59292e..7d8f273b 100644
--- a/scripts/pest-runner.php
+++ b/scripts/pest-runner.php
@@ -24,8 +24,24 @@
$serverVendor = getcwd() . '/server_vendor';
$vendor = getcwd() . '/vendor';
+
+// Pest hardcodes its autoloader at ../../../vendor/autoload.php (pestphp/pest#920),
+// so it needs a `vendor` entry even though this package installs to server_vendor.
+// Create the symlink only for the duration of this run and remove it afterwards, so it
+// never persists to collide with other tooling — notably the console's Ember build,
+// whose addon `vendor/` convention breaks when a dev-linked package exposes this PHP
+// server_vendor symlink there.
+$createdVendorSymlink = false;
if (!file_exists($vendor) && is_dir($serverVendor) && function_exists('symlink')) {
- @symlink($serverVendor, $vendor);
+ $createdVendorSymlink = @symlink($serverVendor, $vendor);
+}
+
+if ($createdVendorSymlink) {
+ register_shutdown_function(static function () use ($vendor): void {
+ if (is_link($vendor)) {
+ @unlink($vendor);
+ }
+ });
}
$bootstrap = getcwd() . '/scripts/pest-bootstrap.php';
diff --git a/server/src/Console/Commands/NotifyStorefrontOrderNearby.php b/server/src/Console/Commands/NotifyStorefrontOrderNearby.php
index 52ebd096..cda52533 100644
--- a/server/src/Console/Commands/NotifyStorefrontOrderNearby.php
+++ b/server/src/Console/Commands/NotifyStorefrontOrderNearby.php
@@ -41,7 +41,7 @@ public function handle()
function ($order) {
$origin = $order->payload->getPickupOrFirstWaypoint();
$destination = $order->payload->getDropoffOrLastWaypoint();
- $matrix = Utils::getDrivingDistanceAndTime($origin, $destination);
+ $matrix = $this->getDistanceMatrix($origin, $destination);
$distance = $matrix->distance;
$time = $matrix->time;
@@ -61,6 +61,11 @@ function ($order) {
);
}
+ protected function getDistanceMatrix($origin, $destination): object
+ {
+ return Utils::getDrivingDistanceAndTime($origin, $destination);
+ }
+
/**
* Fetches active storefront orders based on certain criteria.
*/
diff --git a/server/src/Console/Commands/PurgeExpiredCarts.php b/server/src/Console/Commands/PurgeExpiredCarts.php
index 2b3a4f96..ccc2db22 100644
--- a/server/src/Console/Commands/PurgeExpiredCarts.php
+++ b/server/src/Console/Commands/PurgeExpiredCarts.php
@@ -29,17 +29,17 @@ class PurgeExpiredCarts extends Command
public function handle()
{
$dbConnection = DB::connection(config('storefront.connection.db'));
+ $schema = $dbConnection->getSchemaBuilder();
- // Disable foreign key checks for the correct connection
- $dbConnection->statement('SET FOREIGN_KEY_CHECKS=0;');
+ $schema->disableForeignKeyConstraints();
- // Delete expired carts
- $dbDeletedCount = $dbConnection->table('carts')
- ->where('expires_at', '<', now())
- ->delete();
-
- // Re-enable foreign key checks
- $dbConnection->statement('SET FOREIGN_KEY_CHECKS=1;');
+ try {
+ $dbDeletedCount = $dbConnection->table('carts')
+ ->where('expires_at', '<', now())
+ ->delete();
+ } finally {
+ $schema->enableForeignKeyConstraints();
+ }
// Log output
$this->info("Successfully deleted {$dbDeletedCount} expired carts.");
diff --git a/server/src/Console/Commands/SendOrderNotification.php b/server/src/Console/Commands/SendOrderNotification.php
index d6e7ed53..224f2897 100644
--- a/server/src/Console/Commands/SendOrderNotification.php
+++ b/server/src/Console/Commands/SendOrderNotification.php
@@ -55,7 +55,7 @@ public function handle()
}
// Attempt to find the order
- $order = Order::where('public_id', $orderId)->first();
+ $order = $this->findOrder($orderId);
if (!$order) {
$this->error('Order not found!');
@@ -92,19 +92,7 @@ public function handle()
// nearby notification requires more arguments
try {
- if ($event === 'nearby') {
- $origin = $order->payload->getPickupOrFirstWaypoint();
- $destination = $order->payload->getDropoffOrLastWaypoint();
- $matrix = Utils::getDrivingDistanceAndTime($origin, $destination);
- $distance = $matrix->distance;
- $time = $matrix->time;
-
- // Trigger notification
- $order->customer->notify(new $notificationClass($order, $distance, $time));
- } else {
- // Trigger notification
- $order->customer->notify(new $notificationClass($order));
- }
+ $this->sendNotification($order, $event, $notificationClass);
} catch (\Exception $e) {
$this->error($e->getMessage());
@@ -115,4 +103,38 @@ public function handle()
return 0;
}
+
+ /**
+ * Resolve the order targeted by the command.
+ */
+ protected function findOrder(?string $orderId): ?Order
+ {
+ return Order::where('public_id', $orderId)->first();
+ }
+
+ /**
+ * Send the resolved notification to the order customer.
+ */
+ protected function sendNotification(Order $order, string $event, string $notificationClass): void
+ {
+ if ($event === 'nearby') {
+ $origin = $order->payload->getPickupOrFirstWaypoint();
+ $destination = $order->payload->getDropoffOrLastWaypoint();
+ $matrix = $this->getDistanceMatrix($origin, $destination);
+
+ $order->customer->notify(new $notificationClass($order, $matrix->distance, $matrix->time));
+
+ return;
+ }
+
+ $order->customer->notify(new $notificationClass($order));
+ }
+
+ /**
+ * Resolve driving distance and time for nearby-order notifications.
+ */
+ protected function getDistanceMatrix($origin, $destination): object
+ {
+ return Utils::getDrivingDistanceAndTime($origin, $destination);
+ }
}
diff --git a/server/src/Http/Controllers/ActionController.php b/server/src/Http/Controllers/ActionController.php
index 1cea2c40..00920ab7 100644
--- a/server/src/Http/Controllers/ActionController.php
+++ b/server/src/Http/Controllers/ActionController.php
@@ -8,6 +8,7 @@
use Fleetbase\Storefront\Models\Store;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Log;
class ActionController extends Controller
{
@@ -31,8 +32,8 @@ public function getStoreCount(Request $request)
public function getMetrics(Request $request)
{
$store = $request->input('store');
- $start = $request->has('start') ? Carbon::fromString($request->input('start'))->toDateTimeString() : Carbon::now()->startOfMonth()->toDateTimeString();
- $end = $request->has('end') ? Carbon::fromString($request->input('end'))->toDateTimeString() : Carbon::now()->toDateTimeString();
+ $start = $request->has('start') ? Carbon::parse($request->input('start'))->startOfDay() : Carbon::now()->startOfMonth();
+ $end = $request->has('end') ? Carbon::parse($request->input('end'))->endOfDay() : Carbon::now()->endOfDay();
// default metrics
$metrics = [
@@ -90,7 +91,9 @@ public function getMetrics(Request $request)
->whereNull('deleted_at')
->get()
->sum(function ($order) {
- return data_get($order, 'meta.total');
+ $orderTotal = data_get($order, 'meta.total');
+
+ return is_numeric($orderTotal) ? (float) $orderTotal : (float) data_get($order, 'transaction.amount', 0);
});
return response()->json($metrics);
@@ -146,7 +149,7 @@ public function sendPushNotification(Request $request)
$sentCount++;
} catch (\Exception $e) {
// Log error but continue with other customers
- \Log::error('Failed to send push notification to customer: ' . $customer->uuid, ['error' => $e->getMessage()]);
+ Log::error('Failed to send push notification to customer: ' . $customer->uuid, ['error' => $e->getMessage()]);
}
}
diff --git a/server/src/Http/Controllers/AddonCategoryController.php b/server/src/Http/Controllers/AddonCategoryController.php
index e60c2bbd..15c1a912 100644
--- a/server/src/Http/Controllers/AddonCategoryController.php
+++ b/server/src/Http/Controllers/AddonCategoryController.php
@@ -51,10 +51,6 @@ public function createRecord(Request $request)
return new $this->resource($record);
} catch (\Exception $e) {
return response()->error($e->getMessage());
- } catch (\Illuminate\Database\QueryException $e) {
- return response()->error($e->getMessage());
- } catch (\Fleetbase\Exceptions\FleetbaseRequestValidationException $e) {
- return response()->error($e->getErrors());
}
}
@@ -94,10 +90,6 @@ public function updateRecord(Request $request, string $id)
return new $this->resource($record);
} catch (\Exception $e) {
return response()->error($e->getMessage());
- } catch (\Illuminate\Database\QueryException $e) {
- return response()->error($e->getMessage());
- } catch (\Fleetbase\Exceptions\FleetbaseRequestValidationException $e) {
- return response()->error($e->getErrors());
}
}
}
diff --git a/server/src/Http/Controllers/AnalyticsController.php b/server/src/Http/Controllers/AnalyticsController.php
index cedcc76e..0b3ccade 100644
--- a/server/src/Http/Controllers/AnalyticsController.php
+++ b/server/src/Http/Controllers/AnalyticsController.php
@@ -15,7 +15,8 @@
class AnalyticsController extends Controller
{
- private const CANCELED_STATUSES = ['canceled', 'order_canceled'];
+ private const CANCELED_STATUSES = ['canceled', 'order_canceled'];
+ private const COMPLETED_STATUSES = ['completed', 'picked_up'];
public function overview(Request $request)
{
@@ -25,13 +26,13 @@ public function overview(Request $request)
$currentOrders = $this->orders($companyUuid, $start, $end, $store)->get();
$previousOrders = $this->orders($companyUuid, $previousStart, $previousEnd, $store)->get();
- $currency = $store->currency ?? data_get($currentOrders->first(), 'meta.currency', 'USD');
+ $currency = $store->currency ?? data_get($currentOrders->first(), 'meta.currency') ?? data_get($currentOrders->first(), 'transaction.currency', 'USD');
$currentRevenue = $this->sumOrderRevenue($currentOrders);
$previousRevenue = $this->sumOrderRevenue($previousOrders);
$currentOrderCount = $currentOrders->whereNotIn('status', self::CANCELED_STATUSES)->count();
$previousOrderCount = $previousOrders->whereNotIn('status', self::CANCELED_STATUSES)->count();
- $completedOrders = $currentOrders->where('status', 'completed')->count();
- $activeOrders = $currentOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, ['completed']))->count();
+ $completedOrders = $currentOrders->whereIn('status', self::COMPLETED_STATUSES)->count();
+ $activeOrders = $currentOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, self::COMPLETED_STATUSES))->count();
$currentCustomers = $currentOrders->whereNotNull('customer_uuid')->pluck('customer_uuid')->unique()->count();
$previousCustomers = $previousOrders->whereNotNull('customer_uuid')->pluck('customer_uuid')->unique()->count();
$currentAov = $currentOrderCount > 0 ? round($currentRevenue / $currentOrderCount, 2) : 0;
@@ -53,8 +54,8 @@ public function overview(Request $request)
'revenue' => $this->metric($currentRevenue, $previousRevenue, 'money', $currency),
'orders' => $this->metric($currentOrderCount, $previousOrderCount),
'average_order_value' => $this->metric($currentAov, $previousAov, 'money', $currency),
- 'active_orders' => $this->metric($activeOrders, $previousOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, ['completed']))->count()),
- 'completed_orders' => $this->metric($completedOrders, $previousOrders->where('status', 'completed')->count()),
+ 'active_orders' => $this->metric($activeOrders, $previousOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, self::COMPLETED_STATUSES))->count()),
+ 'completed_orders' => $this->metric($completedOrders, $previousOrders->whereIn('status', self::COMPLETED_STATUSES)->count()),
'customers' => $this->metric($currentCustomers, $previousCustomers),
'stores' => $this->metric(Store::where('company_uuid', $companyUuid)->count(), Store::where('company_uuid', $companyUuid)->count()),
'products' => $this->metric($this->productCount($companyUuid, $store), $this->productCount($companyUuid, $store)),
@@ -107,7 +108,7 @@ public function revenueTrend(Request $request)
'summary' => [
'revenue' => array_sum($revenue),
'orders' => array_sum($counts),
- 'currency' => $store->currency ?? data_get($orders->first(), 'meta.currency', 'USD'),
+ 'currency' => $store->currency ?? data_get($orders->first(), 'meta.currency') ?? data_get($orders->first(), 'transaction.currency', 'USD'),
],
]);
}
@@ -263,7 +264,7 @@ private function resolveStore(Request $request): ?Store
private function orders(?string $companyUuid, ?Carbon $start = null, ?Carbon $end = null, ?Store $store = null)
{
- $query = Order::where(['company_uuid' => $companyUuid, 'type' => 'storefront'])->whereNull('deleted_at');
+ $query = Order::with('transaction')->where(['company_uuid' => $companyUuid, 'type' => 'storefront'])->whereNull('deleted_at');
if ($start && $end) {
$query->whereBetween('created_at', [$start, $end]);
@@ -298,7 +299,9 @@ private function checkouts(?string $companyUuid, Carbon $start, Carbon $end, ?St
private function sumOrderRevenue(Collection $orders): float
{
return round($orders->whereNotIn('status', self::CANCELED_STATUSES)->sum(function ($order) {
- return (float) data_get($order, 'meta.total', 0);
+ $orderTotal = data_get($order, 'meta.total');
+
+ return is_numeric($orderTotal) ? (float) $orderTotal : (float) data_get($order, 'transaction.amount', 0);
}), 2);
}
diff --git a/server/src/Http/Controllers/MetricsController.php b/server/src/Http/Controllers/MetricsController.php
index 6b807fdb..6e0b3c24 100644
--- a/server/src/Http/Controllers/MetricsController.php
+++ b/server/src/Http/Controllers/MetricsController.php
@@ -15,8 +15,8 @@ class MetricsController extends Controller
*/
public function all(Request $request)
{
- $start = $request->date('start');
- $end = $request->date('end');
+ $start = $request->date('start')?->startOfDay();
+ $end = $request->date('end')?->endOfDay();
$discover = $request->array('discover', []);
try {
diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php
index 5ab1ffb1..09a6d51c 100644
--- a/server/src/Http/Controllers/OrderController.php
+++ b/server/src/Http/Controllers/OrderController.php
@@ -44,7 +44,7 @@ public function onQueryRecord(Builder $query): void
public function findRecord(Request $request, $id)
{
try {
- $order = Order::findRecordOrFail($id, $this->detailRelations());
+ $order = $this->findOrderRecord($id);
} catch (ModelNotFoundException $exception) {
return response()->error('Order not found', 404);
}
@@ -54,7 +54,32 @@ public function findRecord(Request $request, $id)
];
}
- private function detailRelations(): array
+ protected function findOrderRecord($id): Order
+ {
+ return Order::findRecordOrFail($id, $this->detailRelations());
+ }
+
+ protected function findOrderForAction($uuid, array $relations = []): ?Order
+ {
+ return Order::where('uuid', $uuid)->whereNull('deleted_at')->with($relations)->first();
+ }
+
+ protected function patchOrderConfig(Order $order)
+ {
+ return Storefront::patchOrderConfig($order);
+ }
+
+ protected function createAcceptedActivity($orderConfig)
+ {
+ return Storefront::createAcceptedActivity($orderConfig);
+ }
+
+ protected function notifyOrderAccepted(Order $order): void
+ {
+ $order->customer->notify(new StorefrontOrderAccepted($order));
+ }
+
+ protected function detailRelations(): array
{
return [
'customer',
@@ -77,7 +102,7 @@ private function detailRelations(): array
];
}
- private function orderResponse(Order $order): array
+ protected function orderResponse(Order $order): array
{
return [
'status' => $order->status,
@@ -92,7 +117,7 @@ private function orderResponse(Order $order): array
*/
public function acceptOrder(Request $request)
{
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['customer'])->first();
+ $order = $this->findOrderForAction($request->order, ['customer']);
if (!$order) {
return response()->json([
@@ -101,8 +126,8 @@ public function acceptOrder(Request $request)
}
// Patch order config
- $orderConfig = Storefront::patchOrderConfig($order);
- $activity = Storefront::createAcceptedActivity($orderConfig);
+ $orderConfig = $this->patchOrderConfig($order);
+ $activity = $this->createAcceptedActivity($orderConfig);
// Dispatch already if order is a pickup
if ($order->isMeta('is_pickup')) {
@@ -121,7 +146,7 @@ public function acceptOrder(Request $request)
// Notify customer order was accepted
try {
- $order->customer->notify(new StorefrontOrderAccepted($order));
+ $this->notifyOrderAccepted($order);
} catch (\Exception $e) {
}
@@ -138,14 +163,14 @@ public function markOrderAsReady(Request $request)
$adhoc = $request->boolean('adhoc');
$driver = $request->input('driver');
/** @var Order $order */
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['customer'])->first();
+ $order = $this->findOrderForAction($request->order, ['customer']);
if (!$order) {
return response()->error('No order to update!');
}
// Patch order config
- Storefront::patchOrderConfig($order);
+ $this->patchOrderConfig($order);
if ($order->isMeta('is_pickup')) {
$order->updateStatus('pickup_ready');
@@ -178,14 +203,14 @@ public function markOrderAsReady(Request $request)
public function markOrderAsPreparing(Request $request)
{
/** @var Order $order */
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['customer'])->first();
+ $order = $this->findOrderForAction($request->order, ['customer']);
if (!$order) {
return response()->error('No order to update!');
}
// Patch order config
- $orderConfig = Storefront::patchOrderConfig($order);
+ $orderConfig = $this->patchOrderConfig($order);
// Get preparing activity
$activity = $orderConfig->getActivityByCode('preparing');
@@ -210,7 +235,7 @@ public function markOrderAsPreparing(Request $request)
public function markOrderAsCompleted(Request $request)
{
/** @var Order */
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['customer'])->first();
+ $order = $this->findOrderForAction($request->order, ['customer']);
if (!$order) {
return response()->json([
@@ -219,7 +244,7 @@ public function markOrderAsCompleted(Request $request)
}
// Patch order config
- Storefront::patchOrderConfig($order);
+ $this->patchOrderConfig($order);
$order->updateStatus($order->isMeta('is_pickup') ? 'picked_up' : 'completed');
@@ -229,7 +254,7 @@ public function markOrderAsCompleted(Request $request)
public function unassignDriver(Request $request)
{
/** @var Order */
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['driverAssigned'])->first();
+ $order = $this->findOrderForAction($request->order, ['driverAssigned']);
if (!$order) {
return response()->json([
@@ -256,7 +281,7 @@ public function unassignDriver(Request $request)
*/
public function rejectOrder(Request $request)
{
- $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['customer'])->first();
+ $order = $this->findOrderForAction($request->order, ['customer']);
if (!$order) {
return response()->json([
@@ -265,7 +290,7 @@ public function rejectOrder(Request $request)
}
// Patch order config
- Storefront::patchOrderConfig($order);
+ $this->patchOrderConfig($order);
$order->updateStatus('canceled');
diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php
index 86e5cc6c..0b67731e 100644
--- a/server/src/Http/Controllers/StoreController.php
+++ b/server/src/Http/Controllers/StoreController.php
@@ -17,7 +17,7 @@ class StoreController extends StorefrontController
public function allStores(Request $request)
{
$stores = Store::select(['uuid', 'name', 'description', 'created_at'])
- ->withoutRelations()->where('company_uuid', $request->session()->get('company'))
+ ->where('company_uuid', $request->session()->get('company'))
->get();
return response()->json(['stores' => $stores]);
diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php
index 55fa3f60..c1f00af3 100644
--- a/server/src/Http/Controllers/v1/CartController.php
+++ b/server/src/Http/Controllers/v1/CartController.php
@@ -9,6 +9,11 @@
class CartController extends Controller
{
+ protected function retrieveCart(?string $uniqueId, bool $create = false): Cart
+ {
+ return Cart::retrieve($uniqueId, $create);
+ }
+
/**
* Retrieve or create a cart using a unique identifier. If no unique identifier is provided
* one will be created.
@@ -17,7 +22,7 @@ class CartController extends Controller
*/
public function retrieve(?string $uniqueId = null, Request $request)
{
- $cart = Cart::retrieve($uniqueId, true);
+ $cart = $this->retrieveCart($uniqueId, true);
return new StorefrontCart($cart);
}
@@ -34,11 +39,7 @@ public function add(string $cartId, string $productId, Request $request)
$addons = $request->input('addons', []);
$scheduledAt = $request->input('scheduled_at');
$storeLocationId = $request->input('store_location');
- $cart = Cart::retrieve($cartId);
-
- if (!$cart) {
- return response()->error('Cart was not found or has already been checkout out.');
- }
+ $cart = $this->retrieveCart($cartId);
try {
$cart->add($productId, $quantity, $variants, $addons, $storeLocationId, $scheduledAt);
@@ -62,11 +63,7 @@ public function update(string $cartId, string $cartItemId, Request $request)
$variants = $request->input('variants', null);
$addons = $request->input('addons', null);
$scheduledAt = $request->input('scheduled_at');
- $cart = Cart::retrieve($cartId);
-
- if (!$cart) {
- return response()->error('Cart was not found or has already been checkout out.');
- }
+ $cart = $this->retrieveCart($cartId);
try {
$cart->updateItem($cartItemId, $quantity, $variants, $addons, $scheduledAt);
@@ -86,11 +83,7 @@ public function update(string $cartId, string $cartItemId, Request $request)
*/
public function remove(?string $cartId, ?string $cartItemId, Request $request)
{
- $cart = Cart::retrieve($cartId);
-
- if (!$cart) {
- return response()->error('Cart was not found or has already been checkout out.');
- }
+ $cart = $this->retrieveCart($cartId);
try {
$cart->remove($cartItemId);
@@ -108,11 +101,7 @@ public function remove(?string $cartId, ?string $cartItemId, Request $request)
*/
public function empty(string $cartId)
{
- $cart = Cart::retrieve($cartId);
-
- if (!$cart) {
- return response()->error('Unable to empty cart.');
- }
+ $cart = $this->retrieveCart($cartId);
$cart->empty();
@@ -126,11 +115,7 @@ public function empty(string $cartId)
*/
public function delete(string $cartId)
{
- $cart = Cart::retrieve($cartId);
-
- if (!$cart) {
- return response()->error('Cart was not found or has already been checkout out.');
- }
+ $cart = $this->retrieveCart($cartId);
$cart->delete();
diff --git a/server/src/Http/Controllers/v1/CatalogController.php b/server/src/Http/Controllers/v1/CatalogController.php
index 82bad65e..a6912058 100644
--- a/server/src/Http/Controllers/v1/CatalogController.php
+++ b/server/src/Http/Controllers/v1/CatalogController.php
@@ -21,7 +21,7 @@ public function query(Request $request)
if (session('storefront_store')) {
$results = Catalog::queryWithRequestCached($request, function (&$query) use ($limit, $offset) {
- $query->where('subject_uuid', session('storefront_store'));
+ $query->where('store_uuid', session('storefront_store'));
if ($limit) {
$query->limit($limit);
diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php
index ae0d7e2e..375f96a3 100644
--- a/server/src/Http/Controllers/v1/CheckoutController.php
+++ b/server/src/Http/Controllers/v1/CheckoutController.php
@@ -33,10 +33,136 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
+use Stripe\Exception\AuthenticationException as StripeAuthenticationException;
use Stripe\Exception\InvalidRequestException;
class CheckoutController extends Controller
{
+ private const STRIPE_AUTHENTICATION_ERROR = 'Stripe gateway authentication failed. Verify the configured secret key.';
+
+ private static function hasStripeSecret(Gateway $gateway): bool
+ {
+ $secretKey = data_get($gateway, 'config.secret_key');
+
+ return is_string($secretKey) && trim($secretKey) !== '';
+ }
+
+ private static function stripeAuthenticationError(Gateway $gateway, string $operation)
+ {
+ Log::warning('[Storefront] Stripe gateway authentication failed.', [
+ 'gateway_uuid' => $gateway->uuid,
+ 'sandbox' => $gateway->sandbox,
+ 'operation' => $operation,
+ 'exception' => StripeAuthenticationException::class,
+ ]);
+
+ return response()->apiError(self::STRIPE_AUTHENTICATION_ERROR);
+ }
+
+ protected static function qpayForGateway(Gateway $gateway): QPay
+ {
+ return QPay::instance(
+ $gateway->config->username,
+ $gateway->config->password,
+ $gateway->callback_url
+ );
+ }
+
+ protected function autoAcceptOrder(Order $order): void
+ {
+ Storefront::autoAcceptOrder($order);
+ }
+
+ protected function autoDispatchOrder(Order $order): void
+ {
+ Storefront::autoDispatchOrder($order);
+ }
+
+ protected function createIntegratedVendorOrder(ServiceQuote $serviceQuote, Request $request)
+ {
+ return $serviceQuote->integratedVendor->api()->createOrderFromServiceQuote($serviceQuote, $request);
+ }
+
+ protected function createIntegratedVendorOrderSafely(ServiceQuote $serviceQuote, Request $request): array
+ {
+ try {
+ return [
+ 'order' => $this->createIntegratedVendorOrder($serviceQuote, $request),
+ 'error' => null,
+ ];
+ } catch (\Exception $e) {
+ return [
+ 'order' => null,
+ 'error' => response()->apiError($e->getMessage()),
+ ];
+ }
+ }
+
+ protected function resolveStoreLocationOrigin($origin, Cart $cart)
+ {
+ if ($origin) {
+ return $origin;
+ }
+
+ $storeLocation = collect($cart->items)->map(function ($cartItem) {
+ $storeLocationId = $cartItem->store_location_id ?? null;
+
+ if (!$storeLocationId) {
+ $store = Store::where('public_id', $cartItem->store_id)->first();
+
+ if ($store) {
+ $storeLocationId = Utils::get($store, 'locations.0.public_id');
+ }
+ }
+
+ return $storeLocationId;
+ })->unique()->filter()->map(function ($storeLocationId) {
+ return StoreLocation::where('public_id', $storeLocationId)->first();
+ })->first();
+
+ return $storeLocation ? $storeLocation->place_uuid : null;
+ }
+
+ protected function resolveFoodTruck(Cart $cart): ?FoodTruck
+ {
+ return collect($cart->items)
+ ->map(fn ($cartItem) => data_get($cartItem, 'food_truck_id'))
+ ->unique()
+ ->filter()
+ ->map(fn ($foodTruckId) => FoodTruck::where('public_id', $foodTruckId)->with(['zone', 'serviceArea'])->first())
+ ->first();
+ }
+
+ protected function resolveFoodTruckOrigin(?FoodTruck $foodTruck): ?array
+ {
+ if (!$foodTruck || !$foodTruck->vehicle) {
+ return null;
+ }
+
+ return [
+ 'name' => $foodTruck->name,
+ 'street1' => data_get($foodTruck, 'zone.name'),
+ 'city' => data_get($foodTruck, 'serviceArea.name'),
+ 'country' => data_get($foodTruck, 'serviceArea.country'),
+ 'location' => $foodTruck->vehicle->location,
+ ];
+ }
+
+ protected function applyFoodTruckOrderData(?FoodTruck $foodTruck, array $orderMeta, array $orderInput): array
+ {
+ if (!$foodTruck) {
+ return [$orderMeta, $orderInput];
+ }
+
+ $orderMeta['food_truck_id'] = $foodTruck->public_id;
+ $driverAssigned = $foodTruck->getDriverAssigned();
+ if ($driverAssigned) {
+ $orderInput['driver_assigned_uuid'] = $driverAssigned->uuid;
+ }
+
+ return [$orderMeta, $orderInput];
+ }
+
public function beforeCheckout(InitializeCheckoutRequest $request)
{
$gatewayCode = $request->input('gateway');
@@ -143,7 +269,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate
$currency = $cart->getCurrency();
// check for secret key first
- if (!isset($gateway->config->secret_key)) {
+ if (!static::hasStripeSecret($gateway)) {
return response()->apiError('Gateway not configured correctly!');
}
@@ -151,8 +277,12 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate
\Stripe\Stripe::setApiKey($gateway->config->secret_key);
// Check customer meta for stripe id
- if ($customer->missingMeta('stripe_id')) {
- Storefront::createStripeCustomerForContact($customer);
+ try {
+ if ($customer->missingMeta('stripe_id')) {
+ Storefront::createStripeCustomerForContact($customer);
+ }
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_customer');
}
$ephemeralKey = null;
@@ -162,17 +292,23 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate
['customer' => $customer->getMeta('stripe_id')],
['stripe_version' => '2020-08-27']
);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_ephemeral_key');
} catch (InvalidRequestException $e) {
$errorMessage = $e->getMessage();
if (Str::contains($errorMessage, 'No such customer')) {
// create the customer for this network/store
- Storefront::createStripeCustomerForContact($customer);
- // regenerate key
- $ephemeralKey = \Stripe\EphemeralKey::create(
- ['customer' => $customer->getMeta('stripe_id')],
- ['stripe_version' => '2020-08-27']
- );
+ try {
+ Storefront::createStripeCustomerForContact($customer);
+ // regenerate key
+ $ephemeralKey = \Stripe\EphemeralKey::create(
+ ['customer' => $customer->getMeta('stripe_id')],
+ ['stripe_version' => '2020-08-27']
+ );
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'recreate_customer');
+ }
} else {
return response()->apiError('Error from Stripe: ' . $errorMessage);
}
@@ -192,6 +328,8 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate
try {
$paymentIntent = \Stripe\PaymentIntent::create($paymentIntentData);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_payment_intent');
} catch (\Exception $e) {
return response()->apiError($e->getMessage());
}
@@ -231,13 +369,21 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques
return response()->apiError('Stripe not setup.');
}
+ if (!static::hasStripeSecret($gateway)) {
+ return response()->apiError('Gateway not configured correctly!');
+ }
+
$customer = Customer::findFromCustomerId($customerId);
\Stripe\Stripe::setApiKey($gateway->config->secret_key);
// Ensure customer has a stripe_id
- if ($customer->missingMeta('stripe_id')) {
- Storefront::createStripeCustomerForContact($customer);
+ try {
+ if ($customer->missingMeta('stripe_id')) {
+ Storefront::createStripeCustomerForContact($customer);
+ }
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_setup_customer');
}
// Prepare payment intent data
@@ -286,6 +432,8 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques
'defaultPaymentMethod' => $defaultPaymentMethod,
'customerId' => $customer->getMeta('stripe_id'),
]);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_setup_intent');
} catch (\Exception $e) {
return response()->apiError($e->getMessage());
}
@@ -317,9 +465,12 @@ public function updateStripePaymentIntent(Request $request)
// Retrieve and validate necessary models
$cart = Cart::retrieve($cartId);
+ // Cart::retrieve() always returns either the persisted cart or a new cart instance.
+ // @codeCoverageIgnoreStart
if (!$cart) {
return response()->apiError('Invalid cart ID provided');
}
+ // @codeCoverageIgnoreEnd
$customer = Customer::findFromCustomerId($customerId);
if (!$customer) {
@@ -335,7 +486,7 @@ public function updateStripePaymentIntent(Request $request)
$currency = $cart->getCurrency();
// Check for Stripe secret key
- if (!isset($gateway->config->secret_key)) {
+ if (!static::hasStripeSecret($gateway)) {
return response()->apiError('Gateway not configured correctly!');
}
@@ -343,13 +494,19 @@ public function updateStripePaymentIntent(Request $request)
\Stripe\Stripe::setApiKey($gateway->config->secret_key);
// Ensure customer has a stripe_id
- if ($customer->missingMeta('stripe_id')) {
- Storefront::createStripeCustomerForContact($customer);
+ try {
+ if ($customer->missingMeta('stripe_id')) {
+ Storefront::createStripeCustomerForContact($customer);
+ }
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'create_update_customer');
}
// Retrieve the existing PaymentIntent
try {
$paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'retrieve_payment_intent');
} catch (\Exception $e) {
return response()->apiError('Failed to retrieve PaymentIntent: ' . $e->getMessage());
}
@@ -369,6 +526,8 @@ public function updateStripePaymentIntent(Request $request)
// Update the PaymentIntent
try {
$paymentIntent = \Stripe\PaymentIntent::update($paymentIntentId, $updateData);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'update_payment_intent');
} catch (\Exception $e) {
return response()->apiError('Failed to update PaymentIntent: ' . $e->getMessage());
}
@@ -386,6 +545,8 @@ public function updateStripePaymentIntent(Request $request)
['customer' => $customer->getMeta('stripe_id')],
['stripe_version' => '2020-08-27']
);
+ } catch (StripeAuthenticationException $e) {
+ return static::stripeAuthenticationError($gateway, 'update_ephemeral_key');
} catch (\Exception $e) {
return response()->apiError('Failed to create ephemeral key: ' . $e->getMessage());
}
@@ -441,7 +602,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa
}
// Create qpay instance
- $qpay = QPay::instance($gateway->config->username, $gateway->config->password, $gateway->callback_url);
+ $qpay = static::qpayForGateway($gateway);
if ($gateway->sandbox) {
$qpay = $qpay->useSandbox();
}
@@ -618,11 +779,7 @@ public function captureQPayCallback(Request $request)
}
// Create the QPay instance.
- $qpay = QPay::instance(
- $gateway->config->username,
- $gateway->config->password,
- $gateway->callback_url
- );
+ $qpay = static::qpayForGateway($gateway);
if ($gateway->sandbox) {
$qpay->useSandbox();
@@ -696,7 +853,7 @@ public function captureQPayCallback(Request $request)
*
* @return Order|null The created order or null if already exists/error
*/
- private function createOrderFromCheckout($checkout, $transactionDetails, $notes = null)
+ protected function createOrderFromCheckout($checkout, $transactionDetails, $notes = null)
{
// Define a unique lock key for this specific checkout
$lockKey = 'create-order-checkout-' . $checkout->uuid;
@@ -836,8 +993,16 @@ public function captureOrder(CaptureOrderRequest $request)
}
// get checkout data to create order
+ $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first();
+ if (!$checkout) {
+ return response()->apiError('Checkout session not found.');
+ }
+
$about = Storefront::about();
- $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first();
+ if (!$about) {
+ return response()->apiError('No storefront in request to capture order!');
+ }
+
$customer = $checkout->owner;
$serviceQuote = $checkout->serviceQuote;
$gateway = $checkout->is_cod ? Gateway::cash() : $checkout->gateway;
@@ -889,12 +1054,11 @@ public function captureOrder(CaptureOrderRequest $request)
// if service quote is applied, resolve it
if ($serviceQuote instanceof ServiceQuote && $serviceQuote->fromIntegratedVendor()) {
- // create order with integrated vendor, then resume fleetbase order creation
- try {
- $integratedVendorOrder = $serviceQuote->integratedVendor->api()->createOrderFromServiceQuote($serviceQuote, $request);
- } catch (\Exception $e) {
- return response()->apiError($e->getMessage());
+ $vendorResult = $this->createIntegratedVendorOrderSafely($serviceQuote, $request);
+ if ($vendorResult['error']) {
+ return $vendorResult['error'];
}
+ $integratedVendorOrder = $vendorResult['order'];
}
// setup transaction meta
@@ -979,45 +1143,13 @@ public function captureOrder(CaptureOrderRequest $request)
}
// Check if the order origin is from a food truck via cart property
- $foodTruck = collect($cart->items)
- ->map(function ($cartItem) {
- return data_get($cartItem, 'food_truck_id');
- })
- ->unique()
- ->filter()
- ->map(function ($foodTruckId) {
- return FoodTruck::where('public_id', $foodTruckId)->with(['zone', 'serviceArea'])->first();
- })
- ->first();
+ $foodTruck = $this->resolveFoodTruck($cart);
// Set food truck vehicle location as origin
- if ($foodTruck && $foodTruck->vehicle) {
- $origin = ['name' => $foodTruck->name, 'street1' => data_get($foodTruck, 'zone.name'), 'city' => data_get($foodTruck, 'serviceArea.name'), 'country' => data_get($foodTruck, 'serviceArea.country'), 'location' => $foodTruck->vehicle->location];
- }
-
- // if there is no origin attempt to get from cart
- if (!$origin) {
- $storeLocation = collect($cart->items)->map(function ($cartItem) {
- $storeLocationId = $cartItem->store_location_id;
-
- // if no store location id set, use first locations id
- if (!$storeLocationId) {
- $store = Store::where('public_id', $cartItem->store_id)->first();
-
- if ($store) {
- $storeLocationId = Utils::get($store, 'locations.0.public_id');
- }
- }
+ $foodTruckOrigin = $this->resolveFoodTruckOrigin($foodTruck);
+ $origin = $foodTruckOrigin ?? $origin;
- return $storeLocationId;
- })->unique()->filter()->map(function ($storeLocationId) {
- return StoreLocation::where('public_id', $storeLocationId)->first();
- })->first();
-
- if ($storeLocation) {
- $origin = $storeLocation->place_uuid;
- }
- }
+ $origin = $this->resolveStoreLocationOrigin($origin, $cart);
// convert payload destinations to Place
$origin = Place::createFromMixed($origin);
@@ -1080,14 +1212,7 @@ public function captureOrder(CaptureOrderRequest $request)
$orderInput = [];
// if there is a food truck include it in the order meta
- if ($foodTruck) {
- $orderMeta['food_truck_id'] = $foodTruck->public_id;
- // assign the driver to the food truck driver
- $driverAssigned = $foodTruck->getDriverAssigned();
- if ($driverAssigned) {
- $orderInput['driver_assigned_uuid'] = $driverAssigned->uuid;
- }
- }
+ [$orderMeta, $orderInput] = $this->applyFoodTruckOrderData($foodTruck, $orderMeta, $orderInput);
// initialize order creation input
$orderInput = [
@@ -1127,9 +1252,9 @@ public function captureOrder(CaptureOrderRequest $request)
// if order is auto accepted update status
if ($store->isOption('auto_accept_orders')) {
- Storefront::autoAcceptOrder($order);
+ $this->autoAcceptOrder($order);
if ($store->isOption('auto_dispatch')) {
- Storefront::autoDispatchOrder($order);
+ $this->autoDispatchOrder($order);
}
}
@@ -1161,8 +1286,12 @@ public function captureMultipleOrders(CaptureOrderRequest $request)
}
// get checkout data to create order
+ $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first();
+ if (!$checkout) {
+ return response()->apiError('Checkout session not found.');
+ }
+
$about = Storefront::about();
- $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first();
$customer = $checkout->owner;
$serviceQuote = $checkout->serviceQuote;
$gateway = $checkout->is_cod ? Gateway::cash() : $checkout->gateway;
@@ -1193,12 +1322,11 @@ public function captureMultipleOrders(CaptureOrderRequest $request)
// if service quote is applied, resolve it
if ($serviceQuote instanceof ServiceQuote && $serviceQuote->fromIntegratedVendor()) {
- // create order with integrated vendor, then resume fleetbase order creation
- try {
- $integratedVendorOrder = $serviceQuote->integratedVendor->api()->createOrderFromServiceQuote($serviceQuote, $request);
- } catch (\Exception $e) {
- return response()->apiError($e->getMessage());
+ $vendorResult = $this->createIntegratedVendorOrderSafely($serviceQuote, $request);
+ if ($vendorResult['error']) {
+ return $vendorResult['error'];
}
+ $integratedVendorOrder = $vendorResult['order'];
}
// setup transaction meta
@@ -1289,7 +1417,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request)
$multipleOrders = [];
foreach ($origins as $pickup) {
- $store = Storefront::getStoreFromLocation($pickup);
+ $store = Storefront::getStoreFromLocation($pickup->uuid);
// create payload
$payload = Payload::create([
@@ -1370,9 +1498,9 @@ public function captureMultipleOrders(CaptureOrderRequest $request)
// if order is auto accepted update status
if ($store->isOption('auto_accept_orders')) {
- Storefront::autoAcceptOrder($order);
+ $this->autoAcceptOrder($order);
if ($store->isOption('auto_dispatch')) {
- Storefront::autoDispatchOrder($order);
+ $this->autoDispatchOrder($order);
}
}
@@ -1535,11 +1663,7 @@ public function getCheckoutStatus(Request $request)
if ($qpayInvoiceId) {
// Create QPay instance with correct credentials
- $qpay = QPay::instance(
- $gateway->config->username,
- $gateway->config->password,
- $gateway->callback_url
- );
+ $qpay = static::qpayForGateway($gateway);
if ($gateway->sandbox) {
$qpay->useSandbox();
@@ -1660,7 +1784,8 @@ private static function calculateTipAmount($tip, $subtotal)
$tipAmount = 0;
if (is_string($tip) && Str::endsWith($tip, '%')) {
- $tipAmount = Utils::calculatePercentage(Utils::numbersOnly($tip), $subtotal);
+ $percentage = (float) str_replace(',', '', Str::beforeLast($tip, '%'));
+ $tipAmount = Utils::calculatePercentage($percentage, $subtotal);
} else {
$tipAmount = Utils::numbersOnly($tip);
}
diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php
index 962e833e..3a38bacc 100644
--- a/server/src/Http/Controllers/v1/CustomerController.php
+++ b/server/src/Http/Controllers/v1/CustomerController.php
@@ -157,10 +157,10 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request
}
return response()->json(['status' => 'ok']);
- } catch (\Exception $e) {
- return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error sending verification code.');
} catch (\Twilio\Exceptions\RestException $e) {
return response()->apiError($e->getMessage());
+ } catch (\Exception $e) {
+ return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error sending verification code.');
}
}
@@ -416,7 +416,7 @@ public function login(Request $request)
$user = User::where('email', $identity)->orWhere('phone', static::phone($identity))->first();
- if (!Hash::check($password, $user->password)) {
+ if (!$user || !Hash::check($password, $user->password)) {
return response()->apiError('Authentication failed using password provided.', 401);
}
@@ -518,7 +518,7 @@ public function loginWithApple(Request $request)
try {
// Verify the Apple token using the utility function
- $isValid = AppleVerifier::verifyAppleJwt($identityToken);
+ $isValid = $this->verifyAppleIdentity($identityToken);
if (!$isValid) {
return response()->apiError('Apple ID authentication is not valid.', 400);
}
@@ -664,7 +664,7 @@ public function loginWithGoogle(Request $request)
try {
// Verify the Google ID token using the utility function
- $payload = GoogleVerifier::verifyIdToken($idToken, $clientId);
+ $payload = $this->verifyGoogleIdentity($idToken, $clientId);
if (!$payload) {
return response()->apiError('Google Sign-In authentication is not valid.', 400);
}
@@ -779,6 +779,16 @@ public function verifyCode(Request $request)
return new Customer($contact);
}
+ protected function verifyAppleIdentity(string $identityToken): bool
+ {
+ return AppleVerifier::verifyAppleJwt($identityToken);
+ }
+
+ protected function verifyGoogleIdentity(string $idToken, string $clientId): ?array
+ {
+ return GoogleVerifier::verifyIdToken($idToken, $clientId);
+ }
+
/**
* Patches phone number with international code.
*/
@@ -981,11 +991,7 @@ public function requestPhoneVerification(Request $request)
}
// Check if phone number is already used by another user
- $existingUser = User::where('phone', $phone)
- ->where('uuid', '!=', $user->uuid)
- ->whereNull('deleted_at')
- ->withoutGlobalScopes()
- ->first();
+ $existingUser = $this->findExistingUserByPhone($phone, $user->uuid);
if ($existingUser) {
return response()->apiError('This phone number is already associated with another account.');
@@ -1007,6 +1013,15 @@ public function requestPhoneVerification(Request $request)
}
}
+ protected function findExistingUserByPhone(string $phone, string $excludedUserUuid): ?User
+ {
+ return User::where('phone', $phone)
+ ->where('uuid', '!=', $excludedUserUuid)
+ ->whereNull('deleted_at')
+ ->withoutGlobalScopes()
+ ->first();
+ }
+
/**
* Verifies the phone number using the provided code.
*
diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php
index a1533678..f9746080 100644
--- a/server/src/Http/Controllers/v1/NetworkController.php
+++ b/server/src/Http/Controllers/v1/NetworkController.php
@@ -197,7 +197,7 @@ public function storeLocations(Request $request)
$databaseName = config('database.connections.mysql.database');
$placesTableName = $databaseName . '.places';
- $query = StoreLocation::select(['store_locations.*', $placesTableName . '.location', $placesTableName . '.uuid'])
+ $query = StoreLocation::select(['store_locations.*', $placesTableName . '.location', $placesTableName . '.uuid as place_uuid'])
->join($placesTableName, $placesTableName . '.uuid', '=', 'store_locations.place_uuid')
->whereHas('store', function ($q) use ($tagged, $searchQuery) {
$q->whereHas('networks', function ($q) {
diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php
index 4a24358e..09119489 100644
--- a/server/src/Http/Controllers/v1/OrderController.php
+++ b/server/src/Http/Controllers/v1/OrderController.php
@@ -35,10 +35,10 @@ public function completeOrderPickup(Request $request)
}
// Patch order config
- Storefront::patchOrderConfig($order);
+ $this->patchOrderConfig($order);
// update activity to completed
- $order->updateStatus('completed');
+ $this->updateOrderStatus($order, 'completed');
return response()->json([
'status' => 'ok',
@@ -47,6 +47,16 @@ public function completeOrderPickup(Request $request)
]);
}
+ protected function patchOrderConfig(Order $order)
+ {
+ return Storefront::patchOrderConfig($order);
+ }
+
+ protected function updateOrderStatus(Order $order, string $status)
+ {
+ return $order->updateStatus($status);
+ }
+
/**
* Get receipt for an order based on the payment method type.
*
@@ -180,7 +190,7 @@ private function getQpayEbarimtReceipt(Request $request, Order $order)
*
* @return QPay|JsonResponse The configured QPay instance or error response
*/
- private function initializeQpayGateway()
+ protected function initializeQpayGateway()
{
// Resolve gateway configuration
$gateway = Storefront::findGateway('qpay');
@@ -189,7 +199,7 @@ private function initializeQpayGateway()
}
// Create QPay instance with credentials
- $qpay = QPay::instance(
+ $qpay = $this->createQpay(
$gateway->config->username,
$gateway->config->password,
$gateway->callback_url
@@ -206,6 +216,11 @@ private function initializeQpayGateway()
return $qpay;
}
+ protected function createQpay(?string $username, ?string $password, ?string $callbackUrl): QPay
+ {
+ return QPay::instance($username, $password, $callbackUrl);
+ }
+
/**
* Create an Ebarimt receipt via QPay API.
*
@@ -220,7 +235,7 @@ private function initializeQpayGateway()
*
* @return mixed|JsonResponse The Ebarimt receipt data or error response
*/
- private function createEbarimtReceipt(QPay $qpay, $payment, string $receiverType, ?string $receiver = null)
+ protected function createEbarimtReceipt(QPay $qpay, $payment, string $receiverType, ?string $receiver = null)
{
// Prepare request parameters
$params = [
diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php
index ce1c2b97..456ec44c 100644
--- a/server/src/Http/Controllers/v1/ProductController.php
+++ b/server/src/Http/Controllers/v1/ProductController.php
@@ -303,6 +303,7 @@ public function update($id, UpdateProductRequest $request)
$optionModel = ProductVariantOption::where('public_id', $option['id'])->first();
if ($optionModel) {
$option['uuid'] = $optionModel->uuid;
+ unset($option['id']);
}
}
diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php
index bdc4f765..90a69ecc 100644
--- a/server/src/Http/Controllers/v1/ReviewController.php
+++ b/server/src/Http/Controllers/v1/ReviewController.php
@@ -24,9 +24,10 @@ class ReviewController extends Controller
*/
public function query(Request $request)
{
- $limit = $request->input('limit', false);
- $offset = $request->input('offset', false);
- $sort = $request->input('sort');
+ $results = [];
+ $limit = $request->input('limit', false);
+ $offset = $request->input('offset', false);
+ $sort = $request->input('sort');
if ($sort) {
$this->applySort($request, $sort);
@@ -202,7 +203,8 @@ public function create(CreateReviewRequest $request)
// if files provided
if ($request->filled('files')) {
- $files = $request->input('files');
+ $files = $request->input('files');
+ $uploadedFiles = collect();
foreach ($files as $upload) {
$data = Utils::get($upload, 'data');
@@ -214,7 +216,7 @@ public function create(CreateReviewRequest $request)
$upload = Storage::disk($disk)->put($bucketPath, base64_decode($data), 'public');
// create the file
- $file = File::create([
+ $uploadedFiles->push(File::create([
'company_uuid' => session('company'),
'uploader_uuid' => $customer->user_uuid,
'subject_uuid' => $review->uuid,
@@ -226,11 +228,11 @@ public function create(CreateReviewRequest $request)
'path' => $bucketPath,
'bucket' => $bucket,
'type' => 'storefront_review_upload',
- 'size' => Utils::getBase64ImageSize($data),
- ]);
-
- $review->files->push($file);
+ 'file_size' => Utils::getBase64ImageSize($data),
+ ]));
}
+
+ $review->setRelation('files', $uploadedFiles);
}
return new StorefrontReview($review);
diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php
index a473696c..03407fc1 100644
--- a/server/src/Http/Controllers/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php
@@ -33,21 +33,21 @@ class ServiceQuoteController extends Controller
*/
public function fromCart(GetServiceQuoteFromCart $request)
{
- $requestId = CoreUtils::generatePublicId('request');
+ $requestId = CoreUtils::generatePublicId('request');
+ $isNetwork = Str::startsWith(session('storefront_key'), 'network_');
+
+ if ($isNetwork) {
+ return $this->fromCartForNetwork($request);
+ }
+
$origin = $this->getPlaceFromId($request->input('origin'));
$destination = $this->getPlaceFromId($request->input('destination'));
$facilitator = $request->input('facilitator');
$scheduledAt = $request->input('scheduled_at');
$serviceType = $request->input('service_type');
$cart = Cart::retrieve($request->input('cart'));
- $currency = $cart->currency;
$all = $request->boolean('all');
$isRouteOptimized = $request->boolean('is_route_optimized', true);
- $isNetwork = Str::startsWith(session('storefront_key'), 'network_');
-
- if ($isNetwork) {
- return $this->fromCartForNetwork($request);
- }
if (!$origin) {
return response()->error('No delivery origin!');
@@ -57,10 +57,7 @@ public function fromCart(GetServiceQuoteFromCart $request)
return response()->error('No delivery destination!');
}
- // if no cart respond with error
- if (!$cart) {
- return response()->error('Cart session not found!');
- }
+ $currency = $cart->currency;
// if facilitator is an integrated partner resolve service quotes from bridge
if ($facilitator && Utils::isIntegratedVendorId($facilitator)) {
@@ -69,13 +66,14 @@ public function fromCart(GetServiceQuoteFromCart $request)
$q->orWhere('provider', $facilitator);
})->first();
- if ($integratedVendor) {
- try {
- /** @var \Fleetbase\Models\ServiceQuote $serviceQuote */
- $serviceQuote = $integratedVendor->api()->setRequestId($requestId)->getQuoteFromPreliminaryPayload([$origin, $destination], [], $serviceType, $scheduledAt, $isRouteOptimized);
- } catch (\Exception $e) {
- return response()->error($e->getMessage());
- }
+ if (!$integratedVendor) {
+ return response()->error('Integrated vendor not found!');
+ }
+
+ try {
+ $serviceQuote = $this->getIntegratedVendorQuote($integratedVendor, $requestId, [$origin, $destination], $serviceType, $scheduledAt, $isRouteOptimized);
+ } catch (\Exception $e) {
+ return response()->error($e->getMessage());
}
// set origin and destination in service quote meta
@@ -88,7 +86,7 @@ public function fromCart(GetServiceQuoteFromCart $request)
}
// get distance matrix
- $matrix = Utils::getDrivingDistanceAndTime($origin, $destination);
+ $matrix = $this->getDrivingMatrix($origin, $destination);
// create entities from cart items
$entities = collect($cart->items ?? [])->map(function ($cartItem) {
@@ -106,9 +104,7 @@ public function fromCart(GetServiceQuoteFromCart $request)
// get service rates for config type
// $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
- $serviceRates = ServiceRate::getServicableForPlaces([$destination], $orderConfigKey, $currency, function ($q) {
- $q->where('company_uuid', session('company'));
- });
+ $serviceRates = $this->getServiceRates($destination, $orderConfigKey, $currency);
// Convert to collection
$serviceRates = collect($serviceRates);
@@ -120,8 +116,7 @@ public function fromCart(GetServiceQuoteFromCart $request)
if ($integratedVendor) {
try {
- /** @var \Fleetbase\Models\ServiceQuote $serviceQuote */
- $serviceQuote = $integratedVendor->api()->setRequestId($requestId)->getQuoteFromPreliminaryPayload([$origin, $destination], [], $serviceType, $scheduledAt, $isRouteOptimized);
+ $serviceQuote = $this->getIntegratedVendorQuote($integratedVendor, $requestId, [$origin, $destination], $serviceType, $scheduledAt, $isRouteOptimized);
} catch (\Exception $e) {
return response()->error($e->getMessage());
}
@@ -204,7 +199,6 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
$scheduledAt = $request->input('scheduled_at');
$serviceType = $request->input('service_type');
$cart = Cart::retrieve($request->input('cart'));
- $currency = $cart->currency;
$all = $request->boolean('all');
$isRouteOptimized = $request->boolean('is_route_optimized', true);
@@ -213,10 +207,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
return response()->error('No delivery destination!');
}
- // if no cart respond with error
- if (!$cart) {
- return response()->error('Cart session not found!');
- }
+ $currency = $cart->currency;
// collect stores
$storeLocations = collect($cart->items)->map(function ($cartItem) {
@@ -261,13 +252,14 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
$q->orWhere('provider', $facilitator);
})->first();
- if ($integratedVendor) {
- try {
- /** @var \Fleetbase\Models\ServiceQuote $serviceQuote */
- $serviceQuote = $integratedVendor->api()->setRequestId($requestId)->getQuoteFromPreliminaryPayload([...$origins, $destination], [], $serviceType, $scheduledAt, $isRouteOptimized);
- } catch (\Exception $e) {
- return response()->error($e->getMessage());
- }
+ if (!$integratedVendor) {
+ return response()->error('Integrated vendor not found!');
+ }
+
+ try {
+ $serviceQuote = $this->getIntegratedVendorQuote($integratedVendor, $requestId, [...$origins, $destination], $serviceType, $scheduledAt, $isRouteOptimized);
+ } catch (\Exception $e) {
+ return response()->error($e->getMessage());
}
// set origin and destination in service quote meta
@@ -281,7 +273,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
// get distance matrix
// $matrix = Utils::getDrivingDistanceAndTime($origin, $destination);
- $matrix = Utils::distanceMatrix($origins, [$destination]);
+ $matrix = $this->getNetworkDistanceMatrix($origins, $destination);
// create entities from cart items
$entities = collect($cart->items ?? [])->map(function ($cartItem) {
@@ -299,9 +291,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
// get service rates for config type
// $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
- $serviceRates = ServiceRate::getServicableForPlaces([$destination], $orderConfigKey, $currency, function ($q) {
- $q->where('company_uuid', session('company'));
- });
+ $serviceRates = $this->getServiceRates($destination, $orderConfigKey, $currency);
// Convert to collection
$serviceRates = collect($serviceRates);
@@ -313,8 +303,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
if ($integratedVendor) {
try {
- /** @var ServiceQuote $serviceQuote */
- $serviceQuote = $integratedVendor->api()->setRequestId($requestId)->getQuoteFromPreliminaryPayload([...$origins, $destination], [], $serviceType, $scheduledAt, $isRouteOptimized);
+ $serviceQuote = $this->getIntegratedVendorQuote($integratedVendor, $requestId, [...$origins, $destination], $serviceType, $scheduledAt, $isRouteOptimized);
} catch (\Exception $e) {
return response()->error($e->getMessage());
}
@@ -381,6 +370,49 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
return new ServiceQuoteResource($bestQuote);
}
+ /**
+ * Request a preliminary quote from an integrated vendor.
+ */
+ protected function getIntegratedVendorQuote(
+ IntegratedVendor $integratedVendor,
+ string $requestId,
+ array $places,
+ ?string $serviceType,
+ $scheduledAt,
+ bool $isRouteOptimized,
+ ): ServiceQuote {
+ return $integratedVendor
+ ->api()
+ ->setRequestId($requestId)
+ ->getQuoteFromPreliminaryPayload($places, [], $serviceType, $scheduledAt, $isRouteOptimized);
+ }
+
+ /**
+ * Resolve the point-to-point distance matrix used to quote a store order.
+ */
+ protected function getDrivingMatrix(Place $origin, Place $destination): object
+ {
+ return Utils::getDrivingDistanceAndTime($origin, $destination);
+ }
+
+ /**
+ * Resolve the multi-origin distance matrix used to quote a network order.
+ */
+ protected function getNetworkDistanceMatrix($origins, Place $destination): object
+ {
+ return Utils::distanceMatrix($origins, [$destination]);
+ }
+
+ /**
+ * Resolve locally configured service rates for the destination and currency.
+ */
+ protected function getServiceRates(Place $destination, string $orderConfigKey, ?string $currency)
+ {
+ return ServiceRate::getServicableForPlaces([$destination], $orderConfigKey, $currency, function ($query) {
+ $query->where('company_uuid', session('company'));
+ });
+ }
+
/**
* Returns a place from either a place id or store location id.
*/
@@ -408,14 +440,14 @@ public function getPlaceFromId(string|array $id): ?Place
if (Str::startsWith($id, 'vehicle_')) {
$vehicle = Vehicle::where('public_id', $id)->first();
- return Place::createFromCoordinates($vehicle->location);
+ return $vehicle ? Place::createFromCoordinates($vehicle->location) : null;
}
// If food truck
if (Str::startsWith($id, 'food_truck_')) {
$foodTruck = FoodTruck::where('public_id', $id)->with('vehicle')->first();
- return $foodTruck->vehicle ? Place::createFromCoordinates($foodTruck->vehicle->location) : null;
+ return $foodTruck?->vehicle ? Place::createFromCoordinates($foodTruck->vehicle->location) : null;
}
// handle coordinates tooo!
diff --git a/server/src/Http/Middleware/SetStorefrontSession.php b/server/src/Http/Middleware/SetStorefrontSession.php
index 7e63196f..c787aee2 100644
--- a/server/src/Http/Middleware/SetStorefrontSession.php
+++ b/server/src/Http/Middleware/SetStorefrontSession.php
@@ -56,7 +56,7 @@ public function setKey(string $key): void
$session = ['storefront_key' => $key];
if (Str::startsWith($key, 'store')) {
- $store = Store::select(['uuid', 'company_uuid', 'currency'])->where('key', $key)->first();
+ $store = Store::select(['uuid', 'public_id', 'company_uuid', 'currency'])->where('key', $key)->first();
if ($store) {
$session['storefront_store'] = $store->uuid;
@@ -65,7 +65,7 @@ public function setKey(string $key): void
$session['company'] = $store->company_uuid;
}
} elseif (Str::startsWith($key, 'network')) {
- $network = Network::select(['uuid', 'company_uuid', 'currency'])->where('key', $key)->first();
+ $network = Network::select(['uuid', 'public_id', 'company_uuid', 'currency'])->where('key', $key)->first();
if ($network) {
$session['storefront_network'] = $network->uuid;
diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php
index f2d34859..f2907654 100644
--- a/server/src/Http/Resources/Index/Order.php
+++ b/server/src/Http/Resources/Index/Order.php
@@ -14,14 +14,16 @@ class Order extends FleetOpsOrderIndexResource
*/
public function toArray($request): array
{
- $data = parent::toArray($request);
+ $data = parent::toArray($request);
+ $parentMeta = $this->normalizeMeta(data_get($data, 'meta', []));
$data['customer_name'] = $this->customer_name;
$data['transaction_amount'] = $this->transaction_amount;
- $data['meta'] = array_replace(
- $this->normalizeMeta(data_get($data, 'meta', [])),
- $this->storefrontOrderMeta()
- );
+ $data['meta'] = $this->storefrontOrderMeta();
+
+ if (array_key_exists('_index_resource', $parentMeta)) {
+ $data['meta']['_index_resource'] = $parentMeta['_index_resource'];
+ }
return $data;
}
@@ -46,7 +48,14 @@ private function storefrontOrderMeta(): array
'master_order_id',
];
- return array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys));
+ $meta = array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys));
+
+ if (isset($meta['storefront']) && (is_array($meta['storefront']) || is_object($meta['storefront']))) {
+ $storefrontKeys = ['id', 'public_id', 'name', 'logo_url', 'is_store', 'is_network'];
+ $meta['storefront'] = array_intersect_key($this->normalizeMeta($meta['storefront']), array_flip($storefrontKeys));
+ }
+
+ return $meta;
}
private function normalizeMeta($meta): array
diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php
index 91a90b63..e6b04a8b 100644
--- a/server/src/Http/Resources/Order.php
+++ b/server/src/Http/Resources/Order.php
@@ -14,12 +14,10 @@ class Order extends FleetOpsOrderResource
*/
public function toArray($request): array
{
- $data = parent::toArray($request);
- $meta = $this->normalizeMeta(data_get($data, 'meta', []));
-
+ $data = parent::toArray($request);
$data['customer_name'] = $this->customer_name;
$data['transaction_amount'] = $this->transaction_amount;
- $data['meta'] = array_replace($meta, $this->storefrontOrderMeta());
+ $data['meta'] = $this->storefrontOrderMeta();
if ($this->resource->relationLoaded('transaction') && $this->transaction) {
$data['transaction'] = [
@@ -63,6 +61,11 @@ private function storefrontOrderMeta(): array
$meta = array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys));
+ if (isset($meta['storefront']) && (is_array($meta['storefront']) || is_object($meta['storefront']))) {
+ $storefrontKeys = ['id', 'public_id', 'name', 'logo_url', 'is_store', 'is_network'];
+ $meta['storefront'] = array_intersect_key($this->normalizeMeta($meta['storefront']), array_flip($storefrontKeys));
+ }
+
return $meta;
}
diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php
index 42e30b2c..9dced7e9 100644
--- a/server/src/Http/Resources/Product.php
+++ b/server/src/Http/Resources/Product.php
@@ -80,7 +80,7 @@ function ($hour) {
'end' => data_get($hour, 'end'),
]);
},
- $hours->toArray()
+ collect($hours)->toArray()
);
}
@@ -113,16 +113,16 @@ public function mapAddonCategories(Collection|array $addonCategories = [])
'created_at' => $addonCategory->created_at,
'updated_at' => $addonCategory->updated_at,
];
- } else {
- return [
- 'id' => data_get($addonCategory, 'category.public_id'),
- 'name' => data_get($addonCategory, 'name'),
- 'description' => data_get($addonCategory, 'category.description'),
- 'excluded_addons' => $addonCategory->excluded_addons,
- 'addons' => $this->mapProductAddons($addons, $addonCategory->excluded_addons),
- ];
}
+ return [
+ 'id' => data_get($addonCategory, 'category.public_id'),
+ 'name' => data_get($addonCategory, 'name'),
+ 'description' => data_get($addonCategory, 'category.description'),
+ 'excluded_addons' => $addonCategory->excluded_addons,
+ 'addons' => $this->mapProductAddons($addons, $addonCategory->excluded_addons),
+ ];
+
return [];
});
}
diff --git a/server/src/Jobs/DownloadProductImageUrl.php b/server/src/Jobs/DownloadProductImageUrl.php
index 543952c9..f9a151ed 100644
--- a/server/src/Jobs/DownloadProductImageUrl.php
+++ b/server/src/Jobs/DownloadProductImageUrl.php
@@ -51,13 +51,22 @@ public function __construct(Product $product, string $url)
public function handle()
{
// get product record
- $product = Product::find($this->product);
+ $product = Product::where('uuid', $this->product)->first();
+ if (!$product) {
+ return;
+ }
+
// download and save to product as image
- $image = Utils::urlToStorefrontFile($this->url, 'storefront_product', $product);
+ $image = $this->downloadProductImage($product);
// if image is \Fleetbase\Models\File then set as primary image
if ($image instanceof File) {
$product->update(['primary_image_uuid' => $image->uuid]);
}
}
+
+ protected function downloadProductImage(Product $product)
+ {
+ return Utils::urlToStorefrontFile($this->url, 'storefront_product', $product);
+ }
}
diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php
index 55fbb3ed..c9e77166 100644
--- a/server/src/Models/Cart.php
+++ b/server/src/Models/Cart.php
@@ -632,8 +632,12 @@ public static function newCart($uniqueId = null): Cart
/**
* Retrieve a cart by id or unique id.
*/
- public static function retrieve(string $id, bool $excludeCheckedout = true): Cart
+ public static function retrieve(?string $id, bool $excludeCheckedout = true): Cart
{
+ if (is_null($id)) {
+ return static::newCart();
+ }
+
$query = static::where(function ($q) use ($id) {
$q->where('public_id', $id);
$q->orWhere('unique_identifier', $id);
diff --git a/server/src/Models/Customer.php b/server/src/Models/Customer.php
index 01a84d5f..e85d4ed1 100644
--- a/server/src/Models/Customer.php
+++ b/server/src/Models/Customer.php
@@ -93,7 +93,7 @@ public function countStorefrontOrdersFrom($id)
*
* @return Customer|null The customer with the given public ID, or null if none was found
*/
- public static function findFromCustomerId($publicId): self
+ public static function findFromCustomerId($publicId): ?self
{
if (Str::startsWith($publicId, 'customer')) {
$publicId = Str::replaceFirst('customer', 'contact', $publicId);
diff --git a/server/src/Models/Network.php b/server/src/Models/Network.php
index 1817417b..85149263 100644
--- a/server/src/Models/Network.php
+++ b/server/src/Models/Network.php
@@ -297,7 +297,7 @@ public function createCategory(string $name, string $description = '', ?array $m
'owner_uuid' => $this->uuid,
'owner_type' => Utils::getMutationType('network:storefront'),
'parent_uuid' => $parent instanceof Category ? $parent->uuid : null,
- 'icon_file_uuid' => $iconFile->uuid,
+ 'icon_file_uuid' => $iconFile?->uuid,
'for' => 'storefront_network',
'name' => $name,
'description' => $description,
diff --git a/server/src/Models/NotificationChannel.php b/server/src/Models/NotificationChannel.php
index 3c257368..07cdccf1 100644
--- a/server/src/Models/NotificationChannel.php
+++ b/server/src/Models/NotificationChannel.php
@@ -6,6 +6,7 @@
use Fleetbase\Casts\PolymorphicType;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Models\Company;
+use Fleetbase\Models\File;
use Fleetbase\Models\User;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasOptionsAttributes;
diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php
index 96fc2682..4c5a3c2d 100644
--- a/server/src/Models/Store.php
+++ b/server/src/Models/Store.php
@@ -356,11 +356,9 @@ public function getNetworkCategoryUsingId(?string $id)
return null;
}
- try {
- $network = Network::where('uuid', $id)->orWhere('public_id', $id)->first();
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
- return null;
- } catch (\Exception $e) {
+ $network = Network::where('uuid', $id)->orWhere('public_id', $id)->first();
+
+ if (!$network instanceof Network) {
return null;
}
diff --git a/server/src/Models/Vote.php b/server/src/Models/Vote.php
index 29e4a2f9..0d786686 100644
--- a/server/src/Models/Vote.php
+++ b/server/src/Models/Vote.php
@@ -2,6 +2,8 @@
namespace Fleetbase\Storefront\Models;
+use Fleetbase\FleetOps\Models\Contact;
+use Fleetbase\Models\User;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasPublicid;
use Fleetbase\Traits\HasUuid;
diff --git a/server/src/Notifications/PromotionalPushNotification.php b/server/src/Notifications/PromotionalPushNotification.php
index 022412c8..127a6365 100644
--- a/server/src/Notifications/PromotionalPushNotification.php
+++ b/server/src/Notifications/PromotionalPushNotification.php
@@ -79,7 +79,7 @@ public function via($notifiable): array
*/
public function toApn($notifiable)
{
- $client = PushNotification::getApnClient($this->store);
+ $client = $this->getApnClient();
if (!$client) {
return null;
}
@@ -99,7 +99,7 @@ public function toApn($notifiable)
*/
public function toFcm($notifiable)
{
- $notificationChannel = PushNotification::getNotificationChannel('fcm', $this->store);
+ $notificationChannel = $this->getFcmNotificationChannel();
if (!$notificationChannel) {
return null;
}
@@ -108,9 +108,7 @@ public function toFcm($notifiable)
PushNotification::configureFcm($notificationChannel);
// Get FCM Client
- $container = \Illuminate\Container\Container::getInstance();
- $projectManager = new \Kreait\Laravel\Firebase\FirebaseProjectManager($container);
- $client = $projectManager->project($notificationChannel->app_key)->messaging();
+ $client = $this->getFcmClient($notificationChannel);
// Create Notification
$notification = new \NotificationChannels\Fcm\Resources\Notification(
@@ -148,6 +146,33 @@ public function toFcm($notifiable)
->usingClient($client);
}
+ /**
+ * Resolve the APN client configured for the notification store.
+ */
+ protected function getApnClient(): ?\Pushok\Client
+ {
+ return PushNotification::getApnClient($this->store);
+ }
+
+ /**
+ * Resolve the FCM channel configured for the notification store.
+ */
+ protected function getFcmNotificationChannel(): ?\Fleetbase\Storefront\Models\NotificationChannel
+ {
+ return PushNotification::getNotificationChannel('fcm', $this->store);
+ }
+
+ /**
+ * Resolve the Firebase messaging client for the selected channel.
+ */
+ protected function getFcmClient(\Fleetbase\Storefront\Models\NotificationChannel $notificationChannel)
+ {
+ $container = \Illuminate\Container\Container::getInstance();
+ $projectManager = new \Kreait\Laravel\Firebase\FirebaseProjectManager($container);
+
+ return $projectManager->project($notificationChannel->app_key)->messaging();
+ }
+
/**
* Get the array representation of the notification.
*/
diff --git a/server/src/Observers/FoodTruckObserver.php b/server/src/Observers/FoodTruckObserver.php
index 2dcbd53e..e826fd14 100644
--- a/server/src/Observers/FoodTruckObserver.php
+++ b/server/src/Observers/FoodTruckObserver.php
@@ -3,6 +3,7 @@
namespace Fleetbase\Storefront\Observers;
use Fleetbase\Storefront\Models\FoodTruck;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
class FoodTruckObserver
@@ -17,8 +18,11 @@ public function saved(FoodTruck $foodTruck): void
try {
$catalogs = Request::input('foodTruck.catalogs', []);
$foodTruck->setCatalogs($catalogs);
- } catch (\Exception $e) {
- dd($e);
+ } catch (\Throwable $e) {
+ Log::error('Unable to synchronize food truck catalogs.', [
+ 'food_truck_uuid' => $foodTruck->uuid,
+ 'error' => $e->getMessage(),
+ ]);
}
}
}
diff --git a/server/src/Observers/ProductObserver.php b/server/src/Observers/ProductObserver.php
index 397f5bb6..ca9a0b98 100644
--- a/server/src/Observers/ProductObserver.php
+++ b/server/src/Observers/ProductObserver.php
@@ -30,7 +30,9 @@ public function saved(Product $product): void
// set keys on files
foreach ($files as $file) {
$fileRecord = File::where('uuid', $file['uuid'])->first();
- $fileRecord->setKey($product);
+ if ($fileRecord) {
+ $fileRecord->setKey($product);
+ }
}
} catch (\Exception $e) {
Log::error($e->getMessage());
diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php
index 7a461f3c..80d85aba 100644
--- a/server/src/Providers/StorefrontServiceProvider.php
+++ b/server/src/Providers/StorefrontServiceProvider.php
@@ -5,6 +5,9 @@
use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider;
use Fleetbase\Providers\CoreServiceProvider;
+// These dependency guards are only reachable before Composer can load this provider.
+// The test runtime necessarily has both parent providers loaded, so the throw paths cannot execute.
+// @codeCoverageIgnoreStart
if (!class_exists(CoreServiceProvider::class)) {
throw new \Exception('Storefront cannot be loaded without `fleetbase/core-api` installed!');
}
@@ -12,6 +15,7 @@
if (!class_exists(FleetOpsServiceProvider::class)) {
throw new \Exception('Storefront cannot be loaded without `fleetbase/fleetops-api` installed!');
}
+// @codeCoverageIgnoreEnd
/**
* Storefront service provider.
diff --git a/server/src/Rules/CartExists.php b/server/src/Rules/CartExists.php
index 71992087..caad0459 100644
--- a/server/src/Rules/CartExists.php
+++ b/server/src/Rules/CartExists.php
@@ -16,7 +16,10 @@ class CartExists implements Rule
*/
public function passes($attribute, $value)
{
- return Cart::where(['public_id' => $attribute, 'unique_identifier' => $attribute])->exists();
+ return Cart::where(function ($query) use ($value) {
+ $query->where('public_id', $value)
+ ->orWhere('unique_identifier', $value);
+ })->exists();
}
/**
diff --git a/server/src/Support/Metrics.php b/server/src/Support/Metrics.php
index ae970aca..dcab0be1 100644
--- a/server/src/Support/Metrics.php
+++ b/server/src/Support/Metrics.php
@@ -148,7 +148,7 @@ public function ordersInProgress(?callable $callback = null): Metrics
$query = Order::where('company_uuid', $this->company->uuid)
->whereBetween('created_at', [$this->start, $this->end])
->where('type', 'storefront')
- ->whereNotIn('status', ['completed', 'created', 'pending', 'canceled']);
+ ->whereNotIn('status', ['completed', 'picked_up', 'created', 'pending', 'canceled', 'order_canceled']);
if (is_callable($callback)) {
$callback($query);
@@ -164,7 +164,7 @@ public function ordersCompleted(?callable $callback = null): Metrics
$query = Order::where('company_uuid', $this->company->uuid)
->whereBetween('created_at', [$this->start, $this->end])
->where('type', 'storefront')
- ->where('status', 'completed');
+ ->whereIn('status', ['completed', 'picked_up']);
if (is_callable($callback)) {
$callback($query);
@@ -180,7 +180,7 @@ public function ordersCanceled(?callable $callback = null): Metrics
$query = Order::where('company_uuid', $this->company->uuid)
->whereBetween('created_at', [$this->start, $this->end])
->where('type', 'storefront')
- ->where('status', 'canceled');
+ ->whereIn('status', ['canceled', 'order_canceled']);
if (is_callable($callback)) {
$callback($query);
diff --git a/server/src/Support/PushNotification.php b/server/src/Support/PushNotification.php
index 86535e6a..60f07f1c 100644
--- a/server/src/Support/PushNotification.php
+++ b/server/src/Support/PushNotification.php
@@ -40,7 +40,7 @@ public static function createApnMessage(Order $order, string $title, string $bod
public static function createFcmMessage(Order $order, string $title, string $body, string $status, $notifiable = null): ?FcmMessage
{
$storefront = static::getStorefrontFromOrder($order);
- $notificationChannel = static::getNotificationChannel('apn', $storefront, $order);
+ $notificationChannel = static::getNotificationChannel('fcm', $storefront, $order);
if (!$notificationChannel) {
// create fcm message anyway
return new FcmMessage(
@@ -55,9 +55,7 @@ public static function createFcmMessage(Order $order, string $title, string $bod
static::configureFcm($notificationChannel);
// Get FCM Client using Notification Channel
- $container = Container::getInstance();
- $projectManager = new FirebaseProjectManager($container);
- $client = $projectManager->project($notificationChannel->app_key)->messaging();
+ $client = static::getFcmClient($notificationChannel);
// Create Notification
$notification = new FcmNotification(
@@ -91,6 +89,14 @@ public static function createFcmMessage(Order $order, string $title, string $bod
->usingClient($client);
}
+ protected static function getFcmClient(NotificationChannel $notificationChannel)
+ {
+ $container = Container::getInstance();
+ $projectManager = new FirebaseProjectManager($container);
+
+ return $projectManager->project($notificationChannel->app_key)->messaging();
+ }
+
public static function configureFcm(NotificationChannel $notificationChannel)
{
// Convert the channel's config to an array.
diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php
index 4047616e..6ef091ef 100644
--- a/server/src/Support/Storefront.php
+++ b/server/src/Support/Storefront.php
@@ -95,7 +95,7 @@ public static function findAbout($id, $columns = [], $with = []): Store|Network|
public static function getStoreFromLocation(string $id, $columns = [], $with = [])
{
if (is_array($columns)) {
- $columns = array_merge(['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'], $columns);
+ $columns = array_merge(['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'], $columns);
}
return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($id) {
@@ -314,7 +314,11 @@ public static function getOrderConfig(Company|string|null $company): ?OrderConfi
$config = DB::transaction(function () use ($attrs, $companyUuid) {
$existing = OrderConfig::where($attrs)->first();
if ($existing) {
+ // This branch requires a concurrent request to insert between the
+ // pre-transaction lookup and this locked recheck.
+ // @codeCoverageIgnoreStart
return $existing;
+ // @codeCoverageIgnoreEnd
}
return static::createStorefrontConfig($companyUuid);
@@ -752,7 +756,8 @@ public static function calculateTipAmount($tip, $subtotal)
$tipAmount = 0;
if (is_string($tip) && Str::endsWith($tip, '%')) {
- $tipAmount = Utils::calculatePercentage(Utils::numbersOnly($tip), $subtotal);
+ $percentage = (float) str_replace(',', '', Str::beforeLast($tip, '%'));
+ $tipAmount = Utils::calculatePercentage($percentage, $subtotal);
} else {
$tipAmount = Utils::numbersOnly($tip);
}
diff --git a/server/tests/Feature.php b/server/tests/FeatureTest.php
similarity index 94%
rename from server/tests/Feature.php
rename to server/tests/FeatureTest.php
index 44be50e0..009da5ba 100644
--- a/server/tests/Feature.php
+++ b/server/tests/FeatureTest.php
@@ -93,6 +93,10 @@
$customer = new Contact();
$customer->forceFill(['uuid' => 'contact_uuid', 'name' => 'Ada Lovelace']);
+ $customer->setRelation('place', null);
+ $customer->setRelation('places', collect());
+ $customer->setRelation('user', null);
+ $customer->setRelation('customFieldValues', collect());
$transaction = new Transaction();
$transaction->forceFill([
@@ -106,6 +110,7 @@
$payload = new Payload();
$payload->forceFill(['uuid' => 'payload_uuid']);
$payload->setRelation('entities', collect());
+ $payload->setRelation('customFieldValues', collect());
$order->setRelation('customer', $customer);
$order->setRelation('transaction', $transaction);
@@ -113,6 +118,7 @@
$order->setRelation('trackingStatuses', collect());
$order->setRelation('comments', collect());
$order->setRelation('files', collect());
+ $order->setRelation('customFieldValues', collect());
$data = (new StorefrontOrderResource($order))->toArray(request());
@@ -141,6 +147,7 @@
'is_network' => false,
],
])
+ ->and($data['meta'])->not->toHaveKey('unrelated')
->and($data['meta']['storefront'])->not->toHaveKey('extra');
});
@@ -153,7 +160,7 @@
->toContain("->table('ledger_journals')")
->toContain("->where('type', 'storefront_sale')")
->toContain("->where('meta->seed', static::SEED_NAME)")
- ->toContain("->whereIn('meta->order_uuid', \$orderUuids)");
+ ->toContain("->orWhereIn('meta->order_uuid', \$orderUuids)");
});
test('storefront navigator search endpoint is registered and returns navigator routes', function () {
diff --git a/server/tests/Unit/Console/CommandContractsTest.php b/server/tests/Unit/Console/CommandContractsTest.php
new file mode 100644
index 00000000..5eba62cc
--- /dev/null
+++ b/server/tests/Unit/Console/CommandContractsTest.php
@@ -0,0 +1,769 @@
+notifications[] = $notification;
+ }
+}
+
+class TestableSendOrderNotification extends SendOrderNotification
+{
+ public Fleetbase\FleetOps\Models\Order $resolvedOrder;
+ public string $askedOrderId = 'order_public';
+ public string $selectedEvent = 'created';
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->eventToNotification['created'] = CommandNotificationStub::class;
+ $this->eventToNotification['nearby'] = CommandNotificationStub::class;
+ $this->eventToNotification['failing'] = FailingCommandNotificationStub::class;
+ }
+
+ protected function findOrder(?string $orderId): ?Fleetbase\FleetOps\Models\Order
+ {
+ return $this->resolvedOrder;
+ }
+
+ protected function getDistanceMatrix($origin, $destination): object
+ {
+ return (object) ['distance' => 1250, 'time' => 300];
+ }
+
+ public function ask($question, $default = null)
+ {
+ return $this->askedOrderId;
+ }
+
+ public function choice($question, array $choices, $default = null, $attempts = null, $multipleSelections = false)
+ {
+ return $this->selectedEvent;
+ }
+}
+
+class TestableMigrateStripeSandboxCustomers extends MigrateStripeSandboxCustomers
+{
+ public array $options = [];
+
+ public function option($key = null)
+ {
+ return $key === null ? $this->options : ($this->options[$key] ?? null);
+ }
+}
+
+class NearbyCommandOrderStub extends Fleetbase\FleetOps\Models\Order
+{
+ public bool $nearbyMarked = false;
+
+ public function missingMeta($key): bool
+ {
+ return !$this->nearbyMarked;
+ }
+
+ public function updateMeta($key, $value = null): Fleetbase\FleetOps\Models\Order
+ {
+ if ($key === 'storefront_order_nearby') {
+ $this->nearbyMarked = (bool) $value;
+ }
+
+ return $this;
+ }
+}
+
+test('purge carts deletes only expired records and reports the affected count', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('carts');
+ $schema->create('carts', function ($table) {
+ $table->increments('id');
+ $table->timestamp('expires_at');
+ });
+
+ $connection->table('carts')->insert([
+ ['expires_at' => now()->subMinute()],
+ ['expires_at' => now()->subDay()],
+ ['expires_at' => now()->addHour()],
+ ]);
+
+ $buffer = new BufferedOutput();
+ $output = new OutputStyle(new ArrayInput([]), $buffer);
+ $command = new PurgeExpiredCarts();
+ $command->setOutput($output);
+
+ expect($command->handle())->toBe(Command::SUCCESS)
+ ->and($connection->table('carts')->count())->toBe(1)
+ ->and($buffer->fetch())->toContain('Successfully deleted 2 expired carts.');
+});
+
+test('purge carts restores foreign key enforcement when deletion fails', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->getSchemaBuilder()->dropIfExists('carts');
+
+ $command = new PurgeExpiredCarts();
+
+ expect(fn () => $command->handle())->toThrow(Illuminate\Database\QueryException::class)
+ ->and((int) $connection->selectOne('PRAGMA foreign_keys')->foreign_keys)->toBe(1);
+});
+
+test('nearby order command reports an empty candidate set', function () {
+ $buffer = new BufferedOutput();
+ $command = new class extends NotifyStorefrontOrderNearby {
+ public function getActiveStorefrontOrders(): Illuminate\Database\Eloquent\Collection
+ {
+ return new Illuminate\Database\Eloquent\Collection();
+ }
+ };
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBeNull()
+ ->and($buffer->fetch())->toContain('Found (0) Storefront Orders which are Enroute.');
+});
+
+test('nearby order command skips candidates without a usable distance matrix', function () {
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ $point = new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917);
+ $order = (object) [
+ 'public_id' => 'order_public',
+ 'payload' => new class($point) {
+ public function __construct(private object $point)
+ {
+ }
+
+ public function getPickupOrFirstWaypoint(): object
+ {
+ return $this->point;
+ }
+
+ public function getDropoffOrLastWaypoint(): object
+ {
+ return $this->point;
+ }
+ },
+ ];
+ $buffer = new BufferedOutput();
+ $command = new class($order) extends NotifyStorefrontOrderNearby {
+ public function __construct(private object $candidate)
+ {
+ parent::__construct();
+ }
+
+ public function getActiveStorefrontOrders(): Illuminate\Database\Eloquent\Collection
+ {
+ return new Illuminate\Database\Eloquent\Collection([$this->candidate]);
+ }
+ };
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBeNull()
+ ->and($buffer->fetch())->toContain('Found (1) Storefront Orders which are Enroute.')
+ ->not->toContain('is nearby');
+});
+
+test('nearby order command notifies an eligible customer once and records the marker', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ foreach (['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamp('deleted_at')->nullable();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Central Store',
+ ]);
+ $point = new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917);
+ $payload = new class($point) {
+ public function __construct(private object $point)
+ {
+ }
+
+ public function getPickupOrFirstWaypoint(): object
+ {
+ return $this->point;
+ }
+
+ public function getDropoffOrLastWaypoint(): object
+ {
+ return $this->point;
+ }
+ };
+ $customer = new class {
+ public array $notifications = [];
+
+ public function notify($notification): void
+ {
+ $this->notifications[] = $notification;
+ }
+ };
+ $order = new NearbyCommandOrderStub();
+ $order->forceFill([
+ 'public_id' => 'order_public',
+ 'meta' => ['storefront_id' => 'store_public'],
+ ]);
+ $order->setRelation('payload', $payload);
+ $order->setRelation('customer', $customer);
+ $buffer = new BufferedOutput();
+ $command = new class($order) extends NotifyStorefrontOrderNearby {
+ public function __construct(private NearbyCommandOrderStub $candidate)
+ {
+ parent::__construct();
+ }
+
+ public function getActiveStorefrontOrders(): Illuminate\Database\Eloquent\Collection
+ {
+ return new Illuminate\Database\Eloquent\Collection([$this->candidate]);
+ }
+
+ protected function getDistanceMatrix($origin, $destination): object
+ {
+ return (object) ['distance' => 1200, 'time' => 300];
+ }
+ };
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBeNull()
+ ->and($customer->notifications)->toHaveCount(1)
+ ->and($customer->notifications[0])->toBeInstanceOf(
+ Fleetbase\Storefront\Notifications\StorefrontOrderNearby::class
+ )->and($order->nearbyMarked)->toBeTrue()
+ ->and($buffer->fetch())->toContain('is nearby');
+
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ $method = new ReflectionMethod(NotifyStorefrontOrderNearby::class, 'getDistanceMatrix');
+ $matrix = $method->invoke(new NotifyStorefrontOrderNearby(), $point, $point);
+
+ expect($matrix->distance)->toBe(0.0)
+ ->and($matrix->time)->toBe(0.0);
+});
+
+test('manual notification command rejects an unknown order without dispatching', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('customer_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $buffer = new BufferedOutput();
+ $command = new SendOrderNotification();
+ $command->setInput(new ArrayInput([
+ '--id' => 'order_missing',
+ '--event' => 'created',
+ ], $command->getDefinition()));
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBe(1)
+ ->and($buffer->fetch())->toContain('Order not found!');
+});
+
+test('manual notification command rejects an order without a customer', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->dropIfExists('contacts');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('customer_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->string('uuid')->primary();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_without_customer',
+ 'customer_uuid' => null,
+ ]);
+
+ $buffer = new BufferedOutput();
+ $command = new SendOrderNotification();
+ $command->setInput(new ArrayInput([
+ '--id' => 'order_without_customer',
+ '--event' => 'created',
+ ], $command->getDefinition()));
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBe(1)
+ ->and($buffer->fetch())->toContain('Order does not have an associated customer!');
+});
+
+test('manual notification command rejects unsupported event names', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->dropIfExists('contacts');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('customer_uuid');
+ $table->string('customer_type');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('name')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'name' => 'Ada Buyer',
+ 'type' => 'customer',
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_public',
+ 'customer_uuid' => 'contact_uuid',
+ 'customer_type' => Fleetbase\Models\Contact::class,
+ ]);
+
+ $buffer = new BufferedOutput();
+ $command = new SendOrderNotification();
+ $command->setInput(new ArrayInput([
+ '--id' => 'order_public',
+ '--event' => 'unsupported',
+ ], $command->getDefinition()));
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBe(1)
+ ->and($buffer->fetch())->toContain('Invalid event selected!');
+});
+
+test('manual notification command sends ordinary and nearby notifications with resolved context', function () {
+ $customer = new CommandNotificationCustomerStub();
+ $payload = new class {
+ public function getPickupOrFirstWaypoint(): object
+ {
+ return (object) ['public_id' => 'place_pickup'];
+ }
+
+ public function getDropoffOrLastWaypoint(): object
+ {
+ return (object) ['public_id' => 'place_dropoff'];
+ }
+ };
+ $order = new Fleetbase\FleetOps\Models\Order();
+ $order->forceFill(['uuid' => 'order_uuid', 'public_id' => 'order_public']);
+ $order->setRelation('customer', $customer);
+ $order->setRelation('payload', $payload);
+
+ $createdBuffer = new BufferedOutput();
+ $created = new TestableSendOrderNotification();
+ $created->resolvedOrder = $order;
+ $created->setInput(new ArrayInput([
+ '--id' => 'order_public',
+ '--event' => 'created',
+ ], $created->getDefinition()));
+ $created->setOutput(new OutputStyle(new ArrayInput([]), $createdBuffer));
+
+ expect($created->handle())->toBe(0)
+ ->and(CommandNotificationStub::$arguments)->toBe([$order])
+ ->and($createdBuffer->fetch())->toContain("Notification 'created' has been triggered");
+
+ $nearbyBuffer = new BufferedOutput();
+ $nearby = new TestableSendOrderNotification();
+ $nearby->resolvedOrder = $order;
+ $nearby->selectedEvent = 'nearby';
+ $nearby->setInput(new ArrayInput([], $nearby->getDefinition()));
+ $nearby->setOutput(new OutputStyle(new ArrayInput([]), $nearbyBuffer));
+
+ expect($nearby->handle())->toBe(0)
+ ->and(CommandNotificationStub::$arguments)->toBe([$order, 1250, 300])
+ ->and($nearbyBuffer->fetch())->toContain("Notification 'nearby' has been triggered")
+ ->and($customer->notifications)->toHaveCount(2);
+});
+
+test('manual notification command reports notification construction failures without crashing', function () {
+ $order = new Fleetbase\FleetOps\Models\Order();
+ $order->forceFill(['uuid' => 'order_uuid', 'public_id' => 'order_public']);
+ $order->setRelation('customer', new CommandNotificationCustomerStub());
+
+ $buffer = new BufferedOutput();
+ $command = new TestableSendOrderNotification();
+ $command->resolvedOrder = $order;
+ $command->setInput(new ArrayInput([
+ '--id' => 'order_public',
+ '--event' => 'failing',
+ ], $command->getDefinition()));
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->handle())->toBe(0)
+ ->and($buffer->fetch())->toContain('Notification construction failed');
+});
+
+test('manual notification command resolves local distance matrices without external providers', function () {
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ $point = new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917);
+ $resolver = new ReflectionMethod(SendOrderNotification::class, 'getDistanceMatrix');
+ $matrix = $resolver->invoke(new SendOrderNotification(), $point, $point);
+
+ expect($matrix)->toBeObject()
+ ->and($matrix->distance)->toBe(0.0)
+ ->and($matrix->time)->toBe(0.0);
+});
+
+test('stripe migration command reports an unknown explicitly selected store', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Container\Container::getInstance()->instance('app', Illuminate\Container\Container::getInstance());
+ Illuminate\Container\Container::getInstance()->forgetInstance('request');
+ Illuminate\Container\Container::getInstance()->instance(
+ 'session',
+ new Illuminate\Session\Store('storefront-tests', new Illuminate\Session\NullSessionHandler())
+ );
+ $buffer = new BufferedOutput();
+ $command = new TestableMigrateStripeSandboxCustomers();
+ $command->options = ['store' => 'store_missing', 'dry-run' => true];
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+ $status = $command->handle();
+
+ expect($status)->toBe(Command::FAILURE)
+ ->and($buffer->fetch())->toContain("Store 'store_missing' not found.");
+});
+
+test('stripe migration command scans all stores and an explicitly selected store', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('gateways');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Model::getConnectionResolver()->connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'name' => 'Corner Store',
+ ]);
+ Illuminate\Container\Container::getInstance()->instance('app', Illuminate\Container\Container::getInstance());
+ Illuminate\Container\Container::getInstance()->instance(
+ 'session',
+ new Illuminate\Session\Store('storefront-tests', new Illuminate\Session\NullSessionHandler())
+ );
+ $buffer = new BufferedOutput();
+ $command = new TestableMigrateStripeSandboxCustomers();
+ $command->options = ['store' => null, 'dry-run' => false];
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+ $status = $command->handle();
+ $display = $buffer->fetch();
+ $selectedBuffer = new BufferedOutput();
+ $selected = new TestableMigrateStripeSandboxCustomers();
+ $selected->options = ['store' => 'store_abcdefgh', 'dry-run' => true];
+ $selected->setOutput(new OutputStyle(new ArrayInput([]), $selectedBuffer));
+ $selectedStatus = $selected->handle();
+ $selectedDisplay = $selectedBuffer->fetch();
+
+ expect($status)->toBe(Command::SUCCESS)
+ ->and($display)->toContain('Starting Stripe customer migration...')
+ ->and($display)->toContain('no Stripe gateway configured')
+ ->and($display)->toContain('Stripe customer migration complete.')
+ ->and($selectedStatus)->toBe(Command::SUCCESS)
+ ->and($selectedDisplay)->toContain('no Stripe gateway configured')
+ ->and($selectedDisplay)->toContain('Stripe customer migration complete.');
+});
+
+test('stripe migration command skips stores without a configured gateway', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('gateways');
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Corner Store',
+ ]);
+ $buffer = new BufferedOutput();
+ $command = new MigrateStripeSandboxCustomers();
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->migrateCustomers($store, false))->toBe(Command::SUCCESS)
+ ->and($buffer->fetch())->toContain('no Stripe gateway configured');
+});
+
+test('stripe migration command refuses to migrate customers through a sandbox gateway', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('gateways');
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->text('config')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Stripe',
+ 'sandbox' => true,
+ 'config' => json_encode(['secret_key' => 'test-key']),
+ ]);
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Corner Store',
+ ]);
+ $buffer = new BufferedOutput();
+ $command = new MigrateStripeSandboxCustomers();
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->migrateCustomers($store, false))->toBe(Command::SUCCESS)
+ ->and($buffer->fetch())->toContain('using a sandbox gateway');
+});
+
+test('stripe migration command safely completes live-store scans when no customers require migration', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('gateways');
+ $schema->dropIfExists('contacts');
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->text('config')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'sandbox' => false,
+ 'config' => json_encode(['secret_key' => 'live-key']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'No Stripe Customer',
+ 'meta' => json_encode([]),
+ ]);
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Corner Store',
+ ]);
+ $buffer = new BufferedOutput();
+ $command = new MigrateStripeSandboxCustomers();
+ $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer));
+
+ expect($command->migrateCustomers($store, false))->toBe(Command::SUCCESS)
+ ->and($buffer->fetch())->toContain('Will have sandbox customers migrated');
+});
+
+test('stripe migration command distinguishes test customers from already-live customers without writes', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('gateways');
+ $schema->dropIfExists('contacts');
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->text('config')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'sandbox' => false,
+ 'config' => json_encode(['secret_key' => 'sk_live_store']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'meta' => json_encode(['stripe_id' => 'cus_test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Corner Store',
+ ]);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ return [json_encode(['error' => [
+ 'message' => 'No such customer',
+ 'type' => 'invalid_request_error',
+ ]]), 404, []];
+ }
+ });
+ $dryRunBuffer = new BufferedOutput();
+ $dryRun = new MigrateStripeSandboxCustomers();
+ $dryRun->setOutput(new OutputStyle(new ArrayInput([]), $dryRunBuffer));
+
+ expect($dryRun->migrateCustomers($store, true))->toBe(Command::SUCCESS)
+ ->and($dryRunBuffer->fetch())->toContain('Would migrate test Stripe ID cus_test to live');
+
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ return [json_encode([
+ 'id' => 'cus_test',
+ 'object' => 'customer',
+ 'livemode' => true,
+ ]), 200, []];
+ }
+ });
+ $liveBuffer = new BufferedOutput();
+ $live = new MigrateStripeSandboxCustomers();
+ $live->setOutput(new OutputStyle(new ArrayInput([]), $liveBuffer));
+
+ expect($live->migrateCustomers($store, false))->toBe(Command::SUCCESS)
+ ->and($liveBuffer->fetch())->toContain('is already a live customer');
+
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if ($method === 'post') {
+ return [json_encode([
+ 'id' => 'cus_live_new',
+ 'object' => 'customer',
+ 'livemode' => true,
+ ]), 200, []];
+ }
+
+ return [json_encode(['error' => [
+ 'message' => 'No such customer',
+ 'type' => 'invalid_request_error',
+ ]]), 404, []];
+ }
+ });
+ $migrationBuffer = new BufferedOutput();
+ $migration = new MigrateStripeSandboxCustomers();
+ $migration->setOutput(new OutputStyle(new ArrayInput([]), $migrationBuffer));
+
+ expect($migration->migrateCustomers($store, false))->toBe(Command::SUCCESS)
+ ->and($migrationBuffer->fetch())->toContain('Migrated test ID cus_test to live Stripe ID cus_live_new');
+
+ $meta = json_decode($connection->table('contacts')->where('uuid', 'customer_uuid')->value('meta'), true);
+
+ expect($meta)->toMatchArray([
+ 'stripe_id_sandbox' => 'cus_test',
+ 'stripe_id' => 'cus_live_new',
+ ]);
+
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+});
diff --git a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php
new file mode 100644
index 00000000..eb30761f
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php
@@ -0,0 +1,334 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['orders', 'transactions', 'stores', 'products', 'carts', 'checkouts', 'contacts', 'files'] as $table) {
+ $schema->dropIfExists($table);
+ }
+
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('status')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('transaction_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('transactions', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->unsignedBigInteger('amount')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('carts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->text('items')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('checkouts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('order_uuid')->nullable();
+ $table->boolean('captured')->default(false);
+ $table->string('currency')->nullable();
+ $table->text('cart_state')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function analyticsRequest(): Request
+{
+ return Request::create('/analytics', 'GET', [
+ 'start' => '2026-07-01',
+ 'end' => '2026-07-02',
+ ]);
+}
+
+test('analytics endpoints return complete zero-state reporting contracts', function () {
+ createAnalyticsControllerSchema();
+ session(['company' => 'company_uuid']);
+ $controller = new AnalyticsController();
+
+ $overview = $controller->overview(analyticsRequest())->getData(true);
+ $trend = $controller->revenueTrend(analyticsRequest())->getData(true);
+ $statuses = $controller->ordersByStatus(analyticsRequest())->getData(true);
+ $products = $controller->topProducts(analyticsRequest())->getData(true);
+ $customers = $controller->customerInsights(analyticsRequest())->getData(true);
+
+ expect($overview['period'])->toBe([
+ 'start' => '2026-07-01',
+ 'end' => '2026-07-02',
+ ])->and($overview['currency'])->toBe('USD')
+ ->and($overview['metrics']['revenue']['value'])->toBe(0)
+ ->and($overview['metrics']['orders']['value'])->toBe(0)
+ ->and($overview['metrics']['cart_conversion']['value'])->toBe(0)
+ ->and($trend['labels'])->toBe(['2026-07-01', '2026-07-02'])
+ ->and($trend['summary'])->toBe([
+ 'revenue' => 0,
+ 'orders' => 0,
+ 'currency' => 'USD',
+ ])
+ ->and($statuses['labels'])->toBe([])
+ ->and($statuses['total'])->toBe(0)
+ ->and($products)->toBe(['products' => []])
+ ->and($customers)->toBe([
+ 'new_customers' => 0,
+ 'returning_customers' => 0,
+ 'repeat_rate' => 0,
+ 'total_customers' => 0,
+ 'known_customers' => 0,
+ ]);
+});
+
+test('analytics aggregates revenue statuses products conversion and returning customers', function () {
+ createAnalyticsControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => 'order_completed',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'storefront',
+ 'status' => 'completed',
+ 'customer_uuid' => 'customer_uuid',
+ 'transaction_uuid' => null,
+ 'meta' => json_encode(['total' => 2500, 'currency' => 'USD']),
+ 'created_at' => '2026-07-01 10:00:00',
+ 'updated_at' => '2026-07-01 10:00:00',
+ ],
+ [
+ 'uuid' => 'order_active',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'storefront',
+ 'status' => 'dispatched',
+ 'customer_uuid' => 'customer_uuid',
+ 'transaction_uuid' => null,
+ 'meta' => json_encode(['total' => 1500, 'currency' => 'USD']),
+ 'created_at' => '2026-07-02 10:00:00',
+ 'updated_at' => '2026-07-02 10:00:00',
+ ],
+ [
+ 'uuid' => 'order_canceled',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'storefront',
+ 'status' => 'canceled',
+ 'customer_uuid' => 'other_customer',
+ 'transaction_uuid' => null,
+ 'meta' => json_encode(['total' => 900, 'currency' => 'USD']),
+ 'created_at' => '2026-07-02 11:00:00',
+ 'updated_at' => '2026-07-02 11:00:00',
+ ],
+ [
+ 'uuid' => 'order_picked_up',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'storefront',
+ 'status' => 'picked_up',
+ 'customer_uuid' => 'pickup_customer',
+ 'transaction_uuid' => 'transaction_pickup',
+ 'meta' => json_encode(['currency' => 'USD']),
+ 'created_at' => '2026-07-02 23:59:59',
+ 'updated_at' => '2026-07-02 23:59:59',
+ ],
+ ]);
+ $connection->table('transactions')->insert([
+ 'uuid' => 'transaction_pickup',
+ 'amount' => 700,
+ 'currency' => 'USD',
+ ]);
+ $connection->table('carts')->insert([
+ [
+ 'uuid' => 'cart_one',
+ 'company_uuid' => 'company_uuid',
+ 'items' => '[]',
+ 'created_at' => '2026-07-01 09:00:00',
+ 'updated_at' => '2026-07-01 09:00:00',
+ ],
+ [
+ 'uuid' => 'cart_two',
+ 'company_uuid' => 'company_uuid',
+ 'items' => '[]',
+ 'created_at' => '2026-07-02 09:00:00',
+ 'updated_at' => '2026-07-02 09:00:00',
+ ],
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'order_uuid' => 'order_completed',
+ 'captured' => true,
+ 'currency' => 'USD',
+ 'cart_state' => json_encode([
+ 'items' => [
+ [
+ 'product_id' => 'product_coffee',
+ 'name' => 'Coffee',
+ 'quantity' => 2,
+ 'subtotal' => 1200,
+ ],
+ ],
+ ]),
+ 'created_at' => '2026-07-01 10:00:00',
+ 'updated_at' => '2026-07-01 10:00:00',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ ]);
+ session(['company' => 'company_uuid']);
+ $controller = new AnalyticsController();
+
+ $overview = $controller->overview(analyticsRequest())->getData(true);
+ $statuses = $controller->ordersByStatus(analyticsRequest())->getData(true);
+ $products = $controller->topProducts(analyticsRequest())->getData(true);
+ $customers = $controller->customerInsights(analyticsRequest())->getData(true);
+
+ expect($overview['metrics']['revenue']['value'])->toBe(4700)
+ ->and($overview['metrics']['orders']['value'])->toBe(3)
+ ->and($overview['metrics']['completed_orders']['value'])->toBe(2)
+ ->and($overview['metrics']['active_orders']['value'])->toBe(1)
+ ->and($overview['metrics']['cancellation_rate']['value'])->toBe(25)
+ ->and($overview['metrics']['cart_conversion']['value'])->toBe(150)
+ ->and($statuses['total'])->toBe(4)
+ ->and($products['products'][0])->toMatchArray([
+ 'id' => 'product_coffee',
+ 'name' => 'Coffee',
+ 'quantity' => 2,
+ 'revenue' => 1200,
+ 'currency' => 'USD',
+ ])
+ ->and($customers)->toMatchArray([
+ 'new_customers' => 2,
+ 'returning_customers' => 1,
+ 'repeat_rate' => 33.33,
+ 'total_customers' => 3,
+ 'known_customers' => 1,
+ ]);
+});
+
+test('analytics store scope filters orders carts checkouts products and malformed cart state', function () {
+ createAnalyticsControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'currency' => 'MNT',
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'store_order',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'storefront',
+ 'status' => 'completed',
+ 'customer_uuid' => 'customer_uuid',
+ 'meta' => json_encode(['total' => 3200, 'storefront_id' => 'store_public']),
+ 'created_at' => '2026-07-01 10:00:00',
+ 'updated_at' => '2026-07-01 10:00:00',
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_matching',
+ 'company_uuid' => 'company_uuid',
+ 'items' => json_encode([['store_id' => 'store_public']]),
+ 'created_at' => '2026-07-01 09:00:00',
+ 'updated_at' => '2026-07-01 09:00:00',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'order_uuid' => 'store_order',
+ 'captured' => true,
+ 'currency' => 'MNT',
+ 'cart_state' => json_encode([
+ 'checkout_store_id' => 'store_public',
+ 'items' => [
+ ['product_id' => 'product_public', 'store_id' => 'store_public', 'quantity' => 2, 'subtotal' => 3200],
+ ['product_id' => 'other_product', 'store_id' => 'other_store'],
+ ['store_id' => 'store_public'],
+ ],
+ ]),
+ 'created_at' => '2026-07-01 10:00:00',
+ 'updated_at' => '2026-07-01 10:00:00',
+ ]);
+ session(['company' => 'company_uuid']);
+ $request = Request::create('/analytics', 'GET', [
+ 'start' => '2026-07-01',
+ 'end' => '2026-07-02',
+ 'store' => 'store_public',
+ ]);
+ $controller = new AnalyticsController();
+
+ $overview = $controller->overview($request)->getData(true);
+ $trend = $controller->revenueTrend($request)->getData(true);
+ $products = $controller->topProducts($request)->getData(true);
+
+ expect($overview['currency'])->toBe('MNT')
+ ->and($overview['metrics']['products']['value'])->toBe(1)
+ ->and($overview['metrics']['cart_conversion']['value'])->toBe(100)
+ ->and($trend['summary'])->toBe(['revenue' => 3200, 'orders' => 1, 'currency' => 'MNT'])
+ ->and($products['products'])->toHaveCount(1)
+ ->and($products['products'][0])->toMatchArray([
+ 'id' => 'product_public',
+ 'quantity' => 2,
+ 'revenue' => 3200,
+ ]);
+
+ expect($controller->cartStateItems(collect(['items' => collect([['id' => 1]])])))->toBe([['id' => 1]])
+ ->and($controller->cartStateItems((object) ['items' => (object) ['id' => 2]]))->toBe(['id' => 2])
+ ->and($controller->cartStateItems('invalid'))->toBe([]);
+});
diff --git a/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php
new file mode 100644
index 00000000..482ced34
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php
@@ -0,0 +1,337 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('categories');
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('parent_uuid')->nullable();
+ $table->string('icon_file_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('icon')->nullable();
+ $table->string('slug')->nullable();
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function categoryApiRequest(array $input = []): Request
+{
+ $request = Request::create('/categories', 'GET', $input);
+ $request->setLaravelSession(new SessionStore(
+ 'category-api-controller-test',
+ new ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+test('storefront category query scopes owner purpose and parent-only results', function () {
+ createCategoryApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('product_addon_categories');
+ $schema->dropIfExists('product_variants');
+ $schema->dropIfExists('files');
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->boolean('is_available')->default(true);
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_addon_categories', function ($table) {
+ $table->increments('id');
+ $table->string('product_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_variants', function ($table) {
+ $table->increments('id');
+ $table->string('product_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('subject_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('categories')->insert([
+ [
+ 'uuid' => 'parent_uuid',
+ 'public_id' => 'category_parent',
+ 'owner_uuid' => 'store_uuid',
+ 'parent_uuid'=> null,
+ 'name' => 'Parent',
+ 'for' => 'storefront_product',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'child_uuid',
+ 'public_id' => 'category_child',
+ 'owner_uuid' => 'store_uuid',
+ 'parent_uuid' => 'parent_uuid',
+ 'name' => 'Child',
+ 'for' => 'storefront_product',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'other_uuid',
+ 'public_id' => 'category_other',
+ 'owner_uuid' => 'other_store',
+ 'parent_uuid'=> null,
+ 'name' => 'Other',
+ 'for' => 'storefront_product',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'category_uuid' => 'child_uuid',
+ 'is_available' => true,
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+
+ $resource = (new CategoryController())->query(categoryApiRequest([
+ 'parents_only' => true,
+ ]));
+ $childResource = (new CategoryController())->query(categoryApiRequest([
+ 'parent' => 'category_parent',
+ 'with_products' => true,
+ ]));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('parent_uuid')
+ ->and($childResource->resource)->toHaveCount(1)
+ ->and($childResource->resource->first()->uuid)->toBe('child_uuid')
+ ->and($childResource->resource->first()->products)->toHaveCount(1)
+ ->and($childResource->resource->first()->products->first()->resource->uuid)->toBe('product_uuid');
+});
+
+test('network category query scopes categories to the active network', function () {
+ createCategoryApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('network_stores');
+ $schema->dropIfExists('networks');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('categories')->insert([
+ [
+ 'uuid' => 'network_category_uuid',
+ 'public_id' => 'category_network',
+ 'owner_uuid' => 'network_uuid',
+ 'parent_uuid'=> null,
+ 'name' => 'Network category',
+ 'for' => 'storefront_network',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'other_network_uuid',
+ 'public_id' => 'category_other',
+ 'owner_uuid' => 'other_network',
+ 'parent_uuid'=> null,
+ 'name' => 'Other',
+ 'for' => 'storefront_network',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'network_child_uuid',
+ 'public_id' => 'category_network_child',
+ 'owner_uuid' => 'network_uuid',
+ 'parent_uuid' => 'network_category_uuid',
+ 'name' => 'Network child',
+ 'for' => 'storefront_network',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'network_category_uuid',
+ ]);
+ session([
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new CategoryController())->query(categoryApiRequest([
+ 'with_stores' => true,
+ 'parents_only' => true,
+ ]));
+ $childResource = (new CategoryController())->query(categoryApiRequest([
+ 'parent' => 'category_network',
+ ]));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('network_category_uuid')
+ ->and($resource->resource->first()->stores)->toHaveCount(1)
+ ->and($resource->resource->first()->stores->first()->uuid)->toBe('store_uuid')
+ ->and($childResource->resource)->toHaveCount(1)
+ ->and($childResource->resource->first()->uuid)->toBe('network_child_uuid');
+});
+
+test('network category query resolves a member store and its child categories', function () {
+ createCategoryApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('network_stores');
+ $schema->dropIfExists('networks');
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('product_addon_categories');
+ $schema->dropIfExists('product_variants');
+ $schema->dropIfExists('files');
+ $schema->dropIfExists('products');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->boolean('is_available')->default(true);
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_addon_categories', function ($table) {
+ $table->increments('id');
+ $table->string('product_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_variants', function ($table) {
+ $table->increments('id');
+ $table->string('product_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('subject_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ ]);
+ $connection->table('categories')->insert([
+ [
+ 'uuid' => 'parent_uuid',
+ 'public_id' => 'category_parent',
+ 'owner_uuid' => 'store_uuid',
+ 'parent_uuid'=> null,
+ 'name' => 'Parent',
+ 'for' => 'storefront_product',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'child_uuid',
+ 'public_id' => 'category_child',
+ 'owner_uuid' => 'store_uuid',
+ 'parent_uuid' => 'parent_uuid',
+ 'name' => 'Child',
+ 'for' => 'storefront_product',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'category_uuid' => 'parent_uuid',
+ 'is_available' => true,
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new CategoryController())->query(categoryApiRequest([
+ 'store' => 'store_public',
+ 'parent' => 'category_parent',
+ ]));
+ $parentResource = (new CategoryController())->query(categoryApiRequest([
+ 'store' => 'store_public',
+ 'parents_only' => true,
+ 'with_products' => true,
+ ]));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('child_uuid')
+ ->and($parentResource->resource)->toHaveCount(1)
+ ->and($parentResource->resource->first()->uuid)->toBe('parent_uuid')
+ ->and($parentResource->resource->first()->products)->toHaveCount(1)
+ ->and($parentResource->resource->first()->products->first()->resource->uuid)->toBe('product_uuid');
+});
diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php
new file mode 100644
index 00000000..50954b11
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php
@@ -0,0 +1,3348 @@
+ 'invoice_checkout'];
+ }
+
+ public function createEbarimtInvoice(?string $invoiceCode = '', ?string $senderInvoiceNo = '', ?string $invoiceReceiverCode = '', array $invoiceReceiverData = [], ?string $invoiceDescription = '', ?string $taxType = '1', ?string $districtCode = '', array $lines = [], ?string $callbackUrl = null)
+ {
+ static::$invoiceKind = 'ebarimt';
+ static::$invoiceArguments = func_get_args();
+
+ return (object) ['invoice_id' => 'invoice_checkout'];
+ }
+}
+
+class CheckoutCaptureFailureStub extends CheckoutController
+{
+ public function captureOrder(CaptureOrderRequest $request)
+ {
+ throw new RuntimeException('Capture failed after payment confirmation');
+ }
+}
+
+class CheckoutOrderAutomationStub extends CheckoutController
+{
+ public static int $accepted = 0;
+ public static int $dispatched = 0;
+
+ protected function autoAcceptOrder(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ static::$accepted++;
+ }
+
+ protected function autoDispatchOrder(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ static::$dispatched++;
+ }
+}
+
+class CheckoutIntegratedVendorStub extends CheckoutOrderAutomationStub
+{
+ public static ?Throwable $vendorFailure = null;
+
+ protected function createIntegratedVendorOrder(ServiceQuote $serviceQuote, Request $request)
+ {
+ if (static::$vendorFailure) {
+ throw static::$vendorFailure;
+ }
+
+ return ['provider_order_id' => 'vendor-order-123'];
+ }
+
+ public function vendorSafely(ServiceQuote $serviceQuote, Request $request): array
+ {
+ return $this->createIntegratedVendorOrderSafely($serviceQuote, $request);
+ }
+}
+
+class CheckoutAutomationControllerProbe extends CheckoutController
+{
+ public function accept(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ $this->autoAcceptOrder($order);
+ }
+
+ public function dispatch(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ $this->autoDispatchOrder($order);
+ }
+
+ public function vendor(ServiceQuote $serviceQuote, Request $request)
+ {
+ return $this->createIntegratedVendorOrder($serviceQuote, $request);
+ }
+
+ public function storeLocationOrigin($origin, Cart $cart)
+ {
+ return $this->resolveStoreLocationOrigin($origin, $cart);
+ }
+
+ public function foodTruck(Cart $cart): ?Fleetbase\Storefront\Models\FoodTruck
+ {
+ return $this->resolveFoodTruck($cart);
+ }
+
+ public function foodTruckOrigin(?Fleetbase\Storefront\Models\FoodTruck $foodTruck): ?array
+ {
+ return $this->resolveFoodTruckOrigin($foodTruck);
+ }
+
+ public function foodTruckOrderData(?Fleetbase\Storefront\Models\FoodTruck $foodTruck, array $meta, array $input): array
+ {
+ return $this->applyFoodTruckOrderData($foodTruck, $meta, $input);
+ }
+}
+
+class CheckoutIntegratedVendorProbe extends Model
+{
+ public function api(): object
+ {
+ return new class {
+ public function createOrderFromServiceQuote(ServiceQuote $serviceQuote, Request $request): array
+ {
+ return ['provider_order_id' => 'provider-probe-order'];
+ }
+ };
+ }
+}
+
+class CheckoutAutomationOrderProbe extends Fleetbase\FleetOps\Models\Order
+{
+ public bool $pickup = true;
+ public array $calls = [];
+
+ public function isMeta($key): bool
+ {
+ return $key === 'is_pickup' && $this->pickup;
+ }
+
+ public function firstDispatchWithActivity(): Fleetbase\FleetOps\Models\Order
+ {
+ $this->calls[] = 'first_dispatch';
+
+ return $this;
+ }
+
+ public function setStatus(?string $status, $andSave = true)
+ {
+ $this->calls[] = 'status:' . $status;
+
+ return $this;
+ }
+
+ public function insertActivity(Fleetbase\FleetOps\Flow\Activity $activity, $location = [], $proof = null): string
+ {
+ $this->calls[] = 'activity:' . $activity->code;
+
+ return 'tracking_status_uuid';
+ }
+
+ public function getLastLocation()
+ {
+ return [];
+ }
+
+ public function updateStatus($code = null)
+ {
+ $this->calls[] = 'update_status:' . $code;
+
+ return $this;
+ }
+}
+
+class TestableCheckoutController extends CheckoutController
+{
+ public static ?Fleetbase\FleetOps\Models\Order $statusFallbackOrder = null;
+ public static ?Throwable $statusFallbackFailure = null;
+
+ protected static function qpayForGateway(Gateway $gateway): Fleetbase\Storefront\Support\QPay
+ {
+ return new CheckoutQPayStub();
+ }
+
+ protected function createOrderFromCheckout($checkout, $transactionDetails, $notes = null)
+ {
+ if (static::$statusFallbackFailure) {
+ if (static::$statusFallbackOrder) {
+ $checkout->update([
+ 'order_uuid' => static::$statusFallbackOrder->uuid,
+ 'captured' => true,
+ ]);
+ }
+
+ throw static::$statusFallbackFailure;
+ }
+
+ return static::$statusFallbackOrder;
+ }
+}
+
+function createCheckoutBoundarySchema(): void
+{
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'networks', 'stores', 'orders'] as $table) {
+ $schema->dropIfExists($table);
+ }
+
+ $schema->create('carts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('checkout_uuid')->nullable();
+ $table->string('customer_id')->nullable();
+ $table->string('unique_identifier')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('discount_code')->nullable();
+ $table->text('items')->nullable();
+ $table->text('events')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->text('config')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->string('callback_url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('service_quotes', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->integer('amount')->default(0);
+ $table->string('currency')->nullable();
+ $table->string('integrated_vendor_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expired_at')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('integrated_vendors', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('checkouts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('network_uuid')->nullable();
+ $table->string('cart_uuid')->nullable();
+ $table->string('gateway_uuid')->nullable();
+ $table->string('service_quote_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('owner_type')->nullable();
+ $table->integer('amount')->default(0);
+ $table->string('currency')->nullable();
+ $table->boolean('is_cod')->default(false);
+ $table->boolean('is_pickup')->default(false);
+ $table->text('options')->nullable();
+ $table->text('cart_state')->nullable();
+ $table->string('token')->nullable();
+ $table->string('order_uuid')->nullable();
+ $table->boolean('captured')->default(false);
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('website')->nullable();
+ $table->string('facebook')->nullable();
+ $table->string('instagram')->nullable();
+ $table->string('twitter')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('timezone')->nullable();
+ $table->string('pod_method')->nullable();
+ $table->text('options')->nullable();
+ $table->text('alertable')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('website')->nullable();
+ $table->string('facebook')->nullable();
+ $table->string('instagram')->nullable();
+ $table->string('twitter')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('timezone')->nullable();
+ $table->string('pod_method')->nullable();
+ $table->text('options')->nullable();
+ $table->text('alertable')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function createCheckoutCaptureExecutionSchema(): void
+{
+ if (!Str::hasMacro('humanize')) {
+ Str::macro('humanize', (new Fleetbase\Expansions\Str())->humanize());
+ }
+ Illuminate\Container\Container::getInstance()->instance('responsecache', new class {
+ public function clear(): void
+ {
+ }
+ });
+ Illuminate\Container\Container::getInstance()->instance('DNS2D', new class {
+ public function getBarcodePNG($value, $type): string
+ {
+ return 'encoded-barcode';
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('DNS2D');
+ Model::setEventDispatcher(new class(Illuminate\Container\Container::getInstance()) extends Illuminate\Events\Dispatcher {
+ public function dispatch($event, $payload = [], $halt = false)
+ {
+ if (is_string($event) && preg_match('/^eloquent\\.(created|updated|deleted|restored|saved):/', $event)) {
+ return [];
+ }
+
+ return parent::dispatch($event, $payload, $halt);
+ }
+ });
+ Model::clearBootedModels();
+ createCheckoutBoundarySchema();
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+
+ foreach (['custom_field_values', 'comments', 'files', 'products', 'transactions', 'transaction_items', 'payloads', 'entities', 'waypoints', 'places', 'store_locations', 'purchase_rates', 'companies'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->dropIfExists('orders');
+
+ $schema->create('transactions', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('gateway_transaction_id')->nullable();
+ $table->string('gateway')->nullable();
+ $table->string('gateway_uuid')->nullable();
+ $table->integer('amount')->default(0);
+ $table->string('currency')->nullable();
+ $table->string('description')->nullable();
+ $table->string('type')->nullable();
+ $table->string('status')->nullable();
+ $table->string('settlement_status')->nullable();
+ $table->timestamp('settled_at')->nullable();
+ $table->integer('settled_amount')->default(0);
+ $table->string('settled_currency')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('transaction_items', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('transaction_uuid')->nullable();
+ $table->integer('amount')->default(0);
+ $table->string('currency')->nullable();
+ $table->text('details')->nullable();
+ $table->string('code')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('payloads', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('pickup_uuid')->nullable();
+ $table->string('dropoff_uuid')->nullable();
+ $table->string('return_uuid')->nullable();
+ $table->string('payment_method')->nullable();
+ $table->integer('cod_amount')->nullable();
+ $table->string('cod_currency')->nullable();
+ $table->string('cod_payment_method')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('entities', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->string('internal_id')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->nullable();
+ $table->integer('sale_price')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('slug')->nullable();
+ $table->text('qr_code')->nullable();
+ $table->text('barcode')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->integer('order')->default(0);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('comments', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('parent_comment_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('custom_field_values', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('custom_field_uuid')->nullable();
+ $table->text('value')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Fleetbase\FleetOps\Models\Entity::expand(
+ 'fromStorefrontProduct',
+ Fleetbase\Storefront\Expansions\EntityExpansion::fromStorefrontProduct()
+ );
+ $schema->create('waypoints', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->integer('order')->default(0);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('street1')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('purchase_rates', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('service_quote_uuid')->nullable();
+ $table->string('transaction_uuid')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('status')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ });
+ $schema->create('companies', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('internal_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('facilitator_uuid')->nullable();
+ $table->string('facilitator_type')->nullable();
+ $table->string('transaction_uuid')->nullable();
+ $table->string('purchase_rate_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('driver_assigned_uuid')->nullable();
+ $table->boolean('adhoc')->default(false);
+ $table->boolean('dispatched')->default(false);
+ $table->timestamp('dispatched_at')->nullable();
+ $table->integer('distance')->nullable();
+ $table->integer('time')->nullable();
+ $table->integer('orchestrator_priority')->nullable();
+ $table->string('type')->nullable();
+ $table->string('status')->nullable();
+ $table->text('meta')->nullable();
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+test('checkout initialization reports a missing configured gateway', function () {
+ createCheckoutBoundarySchema();
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $request = InitializeCheckoutRequest::create('/checkouts/before', 'POST', [
+ 'gateway' => 'missing_gateway',
+ 'customer' => 'customer_missing',
+ 'cart' => 'browser-cart',
+ ]);
+
+ $response = (new CheckoutController())->beforeCheckout($request);
+
+ expect($response->getData(true))->toBe(['error' => 'No gateway configured!']);
+});
+
+test('checkout automation delegates accepted and pickup-dispatched orders to storefront workflows', function () {
+ createCheckoutBoundarySchema();
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('order_configs');
+ $schema->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->text('activities')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Model::getConnectionResolver()->connection('mysql')->table('order_configs')->insert([
+ 'uuid' => 'order_config_uuid',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ Model::getConnectionResolver()->connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'key' => 'store_key',
+ 'name' => 'Automation store',
+ 'options' => '{}',
+ ]);
+ session(['storefront_key' => 'store_key']);
+ $customer = new class extends Model {
+ public function notify($notification): void
+ {
+ }
+ };
+ $order = new CheckoutAutomationOrderProbe();
+ $order->forceFill([
+ 'order_config_uuid' => 'order_config_uuid',
+ 'meta' => ['storefront_id' => 'store_abcdefgh'],
+ ]);
+ $order->setRelation('customer', $customer);
+ $controller = new CheckoutAutomationControllerProbe();
+
+ $controller->accept($order);
+ $controller->dispatch($order);
+ $quote = new ServiceQuote();
+ $quote->setRelation('integratedVendor', new CheckoutIntegratedVendorProbe());
+ $vendorOrder = $controller->vendor($quote, Request::create('/checkout/vendor', 'POST'));
+ $vendorController = new CheckoutIntegratedVendorStub();
+ CheckoutIntegratedVendorStub::$vendorFailure = null;
+ $safeVendorOrder = $vendorController->vendorSafely($quote, Request::create('/checkout/vendor', 'POST'));
+ CheckoutIntegratedVendorStub::$vendorFailure = new RuntimeException('Vendor checkout unavailable');
+ $safeVendorFailure = $vendorController->vendorSafely($quote, Request::create('/checkout/vendor', 'POST'));
+ CheckoutIntegratedVendorStub::$vendorFailure = null;
+ session(['storefront_key' => null]);
+
+ expect($order->calls)->toContain(
+ 'first_dispatch',
+ 'status:accepted',
+ 'activity:accepted',
+ 'update_status:pickup_ready'
+ )->and($vendorOrder)->toBe(['provider_order_id' => 'provider-probe-order'])
+ ->and($safeVendorOrder['order'])->toBe(['provider_order_id' => 'vendor-order-123'])
+ ->and($safeVendorFailure['error']->getData(true))->toBe(['error' => 'Vendor checkout unavailable']);
+});
+
+test('checkout origin resolution honors explicit locations and falls back to the stores first location', function () {
+ createCheckoutCaptureExecutionSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $storefrontSchema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $storefrontSchema->dropIfExists('food_trucks');
+ $storefrontSchema->create('food_trucks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'key' => 'store_key',
+ 'name' => 'Origin store',
+ 'options' => '{}',
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'location_one_uuid',
+ 'public_id' => 'store_location_abcdefgh',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'place_one_uuid',
+ 'name' => 'First location',
+ ],
+ [
+ 'uuid' => 'location_two_uuid',
+ 'public_id' => 'store_location_ijklmnop',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'place_two_uuid',
+ 'name' => 'Second location',
+ ],
+ ]);
+ $explicitCart = new Cart();
+ $explicitCart->forceFill([
+ 'items' => [(object) [
+ 'store_id' => 'store_abcdefgh',
+ 'store_location_id'=> 'store_location_ijklmnop',
+ ]],
+ ]);
+ $fallbackCart = new Cart();
+ $fallbackCart->forceFill([
+ 'items' => [(object) [
+ 'store_id' => 'store_abcdefgh',
+ ]],
+ ]);
+ $controller = new CheckoutAutomationControllerProbe();
+ $foodTruckCart = new Cart();
+ $foodTruckCart->forceFill([
+ 'items' => [(object) ['food_truck_id' => 'food_truck_missing']],
+ ]);
+ $foodTruck = new Fleetbase\Storefront\Models\FoodTruck();
+ $foodTruck->forceFill([
+ 'public_id' => 'food_truck_abcdefgh',
+ 'name' => 'Mobile Kitchen',
+ ]);
+ $foodTruck->setRelation('zone', (object) ['name' => 'Central Zone']);
+ $foodTruck->setRelation('serviceArea', (object) [
+ 'name' => 'Downtown',
+ 'country' => 'MN',
+ ]);
+ $vehicle = new Fleetbase\FleetOps\Models\Vehicle();
+ $vehicle->forceFill([
+ 'location' => new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917),
+ ]);
+ $driver = new Fleetbase\FleetOps\Models\Driver();
+ $driver->forceFill(['uuid' => 'driver_uuid']);
+ $vehicle->setRelation('driver', $driver);
+ $foodTruck->setRelation('vehicle', $vehicle);
+ [$foodTruckMeta, $foodTruckInput] = $controller->foodTruckOrderData(
+ $foodTruck,
+ ['checkout_id' => 'checkout_abcdefgh'],
+ []
+ );
+
+ expect($controller->storeLocationOrigin('existing_origin', $explicitCart))->toBe('existing_origin')
+ ->and($controller->storeLocationOrigin(null, $explicitCart))->toBe('place_two_uuid')
+ ->and($controller->storeLocationOrigin(null, $fallbackCart))->toBe('place_one_uuid')
+ ->and($controller->foodTruck($foodTruckCart))->toBeNull()
+ ->and($controller->foodTruckOrigin(null))->toBeNull()
+ ->and($controller->foodTruckOrigin($foodTruck))->toMatchArray([
+ 'name' => 'Mobile Kitchen',
+ 'street1' => 'Central Zone',
+ 'city' => 'Downtown',
+ 'country' => 'MN',
+ ])
+ ->and($foodTruckMeta['food_truck_id'])->toBe('food_truck_abcdefgh')
+ ->and($foodTruckInput['driver_assigned_uuid'])->toBe('driver_uuid')
+ ->and($controller->foodTruckOrderData(null, ['existing' => true], []))->toBe([
+ ['existing' => true],
+ [],
+ ]);
+});
+
+test('checkout initialization rejects configured but unsupported gateway types', function () {
+ createCheckoutBoundarySchema();
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ Model::getConnectionResolver()->connection('mysql')->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'manual-bank',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'manual-bank',
+ 'config' => json_encode([]),
+ ]);
+
+ $response = (new CheckoutController())->beforeCheckout(
+ InitializeCheckoutRequest::create('/checkouts/before', 'POST', [
+ 'gateway' => 'manual-bank',
+ 'customer' => 'customer_missing',
+ 'cart' => 'browser-cart',
+ 'pickup' => true,
+ ])
+ );
+
+ expect($response->getData(true))->toBe(['error' => 'Unable to initialize checkout!']);
+});
+
+test('checkout initialization creates a cash checkout from persisted customer cart and quote contracts', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'type' => 'customer',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'id' => 'line_one',
+ 'quantity' => 1,
+ 'subtotal' => 2000,
+ ],
+ ]),
+ 'events' => '[]',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'amount' => 500,
+ 'meta' => '{}',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $request = InitializeCheckoutRequest::create('/checkouts/before', 'POST', [
+ 'gateway' => 'cash',
+ 'cash' => true,
+ 'customer' => 'customer_abcdefgh',
+ 'cart' => 'browser-cart',
+ 'serviceQuote' => 'quote_abcdefgh',
+ 'tip' => '10%',
+ 'deliveryTip' => 100,
+ ]);
+
+ $response = (new CheckoutController())->beforeCheckout($request);
+ $checkout = Checkout::query()->first();
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true)['token'])->toBe($checkout->token)
+ ->and($checkout->owner_uuid)->toBe('customer_uuid')
+ ->and($checkout->cart_uuid)->toBe('cart_uuid')
+ ->and($checkout->service_quote_uuid)->toBe('quote_uuid')
+ ->and($checkout->amount)->toBe(2800)
+ ->and($checkout->is_cod)->toBeTrue();
+});
+
+test('checkout initialization dispatches configured stripe and qpay gateway types', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'type' => 'customer',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'amount' => 0,
+ 'meta' => '{}',
+ ]);
+ $connection->table('gateways')->insert([
+ [
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => '{}',
+ ],
+ [
+ 'uuid' => 'qpay_gateway_uuid',
+ 'code' => 'qpay',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'qpay',
+ 'config' => '{}',
+ ],
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $controller = new CheckoutController();
+ $input = [
+ 'customer' => 'customer_abcdefgh',
+ 'cart' => 'browser-cart',
+ 'serviceQuote' => 'quote_abcdefgh',
+ 'pickup' => true,
+ ];
+
+ $stripe = $controller->beforeCheckout(InitializeCheckoutRequest::create(
+ '/checkouts/before',
+ 'POST',
+ [...$input, 'gateway' => 'stripe']
+ ));
+ $qpay = $controller->beforeCheckout(InitializeCheckoutRequest::create(
+ '/checkouts/before',
+ 'POST',
+ [...$input, 'gateway' => 'qpay']
+ ));
+
+ expect($stripe->getData(true))->toBe(['error' => 'Gateway not configured correctly!'])
+ ->and($qpay->getData(true))->toBe(['error' => 'Gateway not configured correctly!']);
+});
+
+test('checkout status validates credentials and unknown checkout sessions', function () {
+ createCheckoutBoundarySchema();
+ $controller = new CheckoutController();
+
+ $missing = $controller->getCheckoutStatus(Request::create('/checkouts/status'));
+ $unknown = $controller->getCheckoutStatus(Request::create('/checkouts/status', 'GET', [
+ 'checkout' => 'checkout_missing',
+ 'token' => 'invalid-token',
+ ]));
+
+ expect($missing->getStatusCode())->toBe(400)
+ ->and($missing->getData(true))->toBe([
+ 'error' => 'Missing required parameters: checkout and token',
+ ])
+ ->and($unknown->getStatusCode())->toBe(404)
+ ->and($unknown->getData(true))->toBe(['error' => 'Checkout not found']);
+});
+
+test('checkout status contains persistence failures behind a stable server error contract', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->drop('checkouts');
+
+ $response = (new CheckoutController())->getCheckoutStatus(Request::create(
+ '/checkouts/status',
+ 'GET',
+ [
+ 'checkout' => 'checkout_abcdefgh',
+ 'token' => 'checkout-token',
+ ]
+ ));
+
+ expect($response->getStatusCode())->toBe(500)
+ ->and($response->getData(true))->toMatchArray([
+ 'error' => 'Failed to retrieve checkout status',
+ ])
+ ->and($response->getData(true)['message'])->toContain('no such table');
+});
+
+test('checkout status reports gateway agnostic pending and completed sessions', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('checkouts')->insert([
+ [
+ 'uuid' => 'checkout_pending_uuid',
+ 'public_id' => 'checkout_pending',
+ 'token' => 'token_pending',
+ 'captured' => false,
+ ],
+ [
+ 'uuid' => 'checkout_complete_uuid',
+ 'public_id' => 'checkout_complete',
+ 'token' => 'token_complete',
+ 'captured' => true,
+ ],
+ ]);
+ $controller = new CheckoutController();
+
+ $pending = $controller->getCheckoutStatus(Request::create('/checkouts/status', 'GET', [
+ 'checkout' => 'checkout_pending',
+ 'token' => 'token_pending',
+ ]));
+ $completed = $controller->getCheckoutStatus(Request::create('/checkouts/status', 'GET', [
+ 'checkout' => 'checkout_complete',
+ 'token' => 'token_complete',
+ ]));
+
+ expect($pending->getData(true))->toBe([
+ 'status' => 'pending',
+ 'checkout' => 'checkout_pending',
+ 'payment' => null,
+ 'order' => null,
+ ])->and($completed->getData(true))->toBe([
+ 'status' => 'completed',
+ 'checkout' => 'checkout_complete',
+ 'payment' => null,
+ 'order' => null,
+ ]);
+});
+
+test('checkout status reports qpay pending paid fallback and provider failure states', function () {
+ createCheckoutCaptureExecutionSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'qpay_gateway_uuid',
+ 'code' => 'qpay',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'qpay',
+ 'sandbox' => true,
+ 'callback_url' => 'https://storefront.test/qpay',
+ 'config' => json_encode([
+ 'username' => 'merchant',
+ 'password' => 'secret',
+ ]),
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'gateway_uuid' => 'qpay_gateway_uuid',
+ 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']),
+ 'token' => 'checkout-token',
+ 'captured' => false,
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'status_order_uuid',
+ 'public_id' => 'order_status',
+ ]);
+ CheckoutQPayStub::$failure = null;
+ CheckoutQPayStub::$paymentCheckResult = (object) ['rows' => []];
+ CheckoutQPayStub::$sandboxUsed = false;
+ TestableCheckoutController::$statusFallbackOrder = null;
+ TestableCheckoutController::$statusFallbackFailure = null;
+ $controller = new TestableCheckoutController();
+ $request = fn () => Request::create('/checkouts/status', 'GET', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'token' => 'checkout-token',
+ ]);
+
+ $pending = $controller->getCheckoutStatus($request());
+ CheckoutQPayStub::$paymentCheckResult = (object) [
+ 'rows' => [
+ (object) [
+ 'payment_id' => 'payment_checkout',
+ 'payment_status' => 'PAID',
+ 'payment_amount' => 2500,
+ 'payment_date' => '2026-07-27 10:00:00',
+ 'payment_wallet' => 'QPay',
+ ],
+ ],
+ ];
+ session(['storefront_key' => null]);
+ TestableCheckoutController::$statusFallbackOrder = Fleetbase\FleetOps\Models\Order::where(
+ 'uuid',
+ 'status_order_uuid'
+ )->firstOrFail();
+ $paid = $controller->getCheckoutStatus($request());
+ TestableCheckoutController::$statusFallbackFailure = new RuntimeException('Concurrent status capture');
+ $raceRecovered = $controller->getCheckoutStatus($request());
+ TestableCheckoutController::$statusFallbackFailure = null;
+ $alreadyCompleted = $controller->getCheckoutStatus($request());
+ CheckoutQPayStub::$failure = new RuntimeException('QPay status unavailable');
+ $failure = $controller->getCheckoutStatus($request());
+ CheckoutQPayStub::$failure = null;
+ $pendingData = $pending->getData(true);
+ $paidData = $paid->getData(true);
+ expect($pendingData['status'])->toBe('pending')
+ ->and($pendingData['payment'])->toBeNull()
+ ->and($pendingData['order'])->toBeNull()
+ ->and($paidData['status'])->toBe('completed')
+ ->and($paidData['payment']['payment_id'])->toBe('payment_checkout')
+ ->and($paidData['payment']['payment_status'])->toBe('PAID')
+ ->and($paidData['payment']['payment_amount'])->toBe(2500)
+ ->and($paidData['payment']['payment_wallet'])->toBe('QPay')
+ ->and($paidData['order']['id'])->toBe('order_status')
+ ->and($raceRecovered->getData(true)['status'])->toBe('completed')
+ ->and($alreadyCompleted->getData(true)['status'])->toBe('completed')
+ ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue()
+ ->and($failure->getStatusCode())->toBe(500)
+ ->and($failure->getData(true))->toBe([
+ 'error' => 'Failed to retrieve checkout status',
+ 'message' => 'QPay status unavailable',
+ ]);
+});
+
+test('customer lookup safely returns null for unknown customer aliases', function () {
+ createCheckoutBoundarySchema();
+
+ expect(Fleetbase\Storefront\Models\Customer::findFromCustomerId('customer_missing'))->toBeNull()
+ ->and(Fleetbase\Storefront\Models\Customer::findFromCustomerId('contact_missing'))->toBeNull();
+});
+
+test('cash checkout persists calculated totals ownership and cart state without a provider call', function () {
+ createCheckoutBoundarySchema();
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'items' => [
+ [
+ 'id' => 'line_one',
+ 'quantity' => 1,
+ 'subtotal' => 1000,
+ ],
+ ],
+ 'events' => [],
+ ]);
+ $customer = new Fleetbase\Storefront\Models\Customer();
+ $customer->forceFill(['uuid' => 'customer_uuid']);
+ $gateway = Gateway::cash();
+ $gateway->forceFill(['uuid' => 'gateway_uuid']);
+ $quote = new ServiceQuote();
+ $quote->forceFill(['uuid' => 'quote_uuid', 'amount' => 300]);
+ $options = (object) [
+ 'is_pickup' => false,
+ 'tip' => '10%',
+ 'delivery_tip' => 100,
+ ];
+
+ $response = CheckoutController::initializeCashCheckout(
+ $customer,
+ $gateway,
+ $quote,
+ $cart,
+ $options,
+ Request::create('/checkout')
+ );
+ $checkout = Checkout::query()->first();
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($checkout)->not->toBeNull()
+ ->and($checkout->company_uuid)->toBe('company_uuid')
+ ->and($checkout->store_uuid)->toBe('store_uuid')
+ ->and($checkout->cart_uuid)->toBe('cart_uuid')
+ ->and($checkout->owner_uuid)->toBe('customer_uuid')
+ ->and($checkout->amount)->toBe(1500)
+ ->and($checkout->currency)->toBe('USD')
+ ->and($checkout->is_cod)->toBeTrue()
+ ->and($checkout->is_pickup)->toBeFalse()
+ ->and($checkout->cart_state['subtotal'])->toBe(1000);
+});
+
+test('cash checkout infers the owning store from a single public store cart item', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ 'currency' => 'USD',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'items' => [
+ [
+ 'id' => 'line_one',
+ 'store_id' => 'store_abcdefgh',
+ 'quantity' => 1,
+ 'subtotal' => 1000,
+ ],
+ ],
+ 'events' => [],
+ ]);
+ $customer = new Fleetbase\Storefront\Models\Customer();
+ $customer->forceFill(['uuid' => 'customer_uuid']);
+ $gateway = Gateway::cash();
+ $quote = new ServiceQuote();
+ $quote->forceFill(['uuid' => 'quote_uuid', 'amount' => 0]);
+
+ CheckoutController::initializeCashCheckout(
+ $customer,
+ $gateway,
+ $quote,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+
+ expect(Checkout::query()->value('store_uuid'))->toBe('store_uuid')
+ ->and(Checkout::query()->value('network_uuid'))->toBe('network_uuid');
+});
+
+test('checkout qpay factory maps persisted gateway credentials into a provider client', function () {
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'callback_url' => 'https://storefront.test/qpay/callback',
+ 'config' => [
+ 'username' => 'merchant',
+ 'password' => 'secret',
+ ],
+ ]);
+ $method = new ReflectionMethod(CheckoutController::class, 'qpayForGateway');
+
+ $qpay = $method->invoke(null, $gateway);
+
+ expect($qpay)->toBeInstanceOf(Fleetbase\Storefront\Support\QPay::class);
+});
+
+test('checkout after hook accepts the completed checkout request contract', function () {
+ $response = (new CheckoutController())->afterCheckout(
+ Request::create('/checkout/after', 'POST', ['checkout' => 'checkout_abcdefgh'])
+ );
+
+ expect($response)->toBeNull();
+});
+
+test('stripe checkout rejects incomplete gateway configuration before provider calls', function () {
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'items' => [],
+ 'events' => [],
+ ]);
+ $customer = new Contact();
+ $customer->forceFill(['uuid' => 'customer_uuid']);
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'uuid' => 'gateway_uuid',
+ 'type' => 'stripe',
+ 'config' => [],
+ ]);
+
+ $response = CheckoutController::initializeStripeCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+ $gateway->config = ['secret_key' => ' '];
+ $blankResponse = CheckoutController::initializeStripeCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+
+ expect($response->getData(true))->toBe(['error' => 'Gateway not configured correctly!'])
+ ->and($blankResponse->getData(true))->toBe(['error' => 'Gateway not configured correctly!']);
+});
+
+test('stripe checkout creates provider intents and persists its checkout token', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'items' => [
+ ['id' => 'line_one', 'quantity' => 1, 'subtotal' => 2000],
+ ],
+ 'events' => [],
+ ]);
+ $customer = new Fleetbase\Storefront\Models\Customer();
+ $customer->forceFill([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_public',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'phone' => '+97699112233',
+ 'meta' => [
+ 'stripe_id' => 'cus_checkout',
+ 'stripe_payment_method_id' => 'pm_checkout',
+ ],
+ ]);
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'type' => 'stripe',
+ 'config' => ['secret_key' => 'sk_test_storefront'],
+ ]);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_contains($absUrl, '/payment_methods/')) {
+ return [json_encode([
+ 'id' => 'pm_checkout',
+ 'object' => 'payment_method',
+ 'customer' => 'cus_checkout',
+ 'type' => 'card',
+ ]), 200, []];
+ }
+
+ if (str_ends_with($absUrl, '/customers')) {
+ return [json_encode([
+ 'id' => 'cus_checkout',
+ 'object' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ ]), 200, []];
+ }
+
+ if (str_contains($absUrl, '/ephemeral_keys')) {
+ return [json_encode([
+ 'id' => 'ephkey_checkout',
+ 'object' => 'ephemeral_key',
+ 'secret' => 'eph_secret',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'customer' => 'cus_checkout',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ });
+
+ $response = CheckoutController::initializeStripeCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true, 'tip' => '10%'],
+ Request::create('/checkout')
+ );
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_without_stripe_uuid',
+ 'public_id' => 'contact_without_stripe',
+ 'type' => 'customer',
+ 'name' => 'New Buyer',
+ 'email' => 'new-buyer@example.test',
+ 'meta' => '{}',
+ ]);
+ $customerWithoutStripe = Fleetbase\Storefront\Models\Customer::where(
+ 'uuid',
+ 'customer_without_stripe_uuid'
+ )->firstOrFail();
+ $createdCustomerResponse = CheckoutController::initializeStripeCheckout(
+ $customerWithoutStripe,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+ $checkout = Checkout::query()->first();
+ $data = $response->getData(true);
+
+ expect($data['paymentIntent'])->toBe('pi_checkout')
+ ->and($data['clientSecret'])->toBe('pi_secret')
+ ->and($data['ephemeralKey'])->toBe('eph_secret')
+ ->and($data['customerId'])->toBe('cus_checkout')
+ ->and($data['token'])->toBe($checkout->token)
+ ->and($checkout->owner_uuid)->toBe('customer_uuid')
+ ->and($checkout->amount)->toBe(2200)
+ ->and($checkout->is_pickup)->toBeTrue()
+ ->and($createdCustomerResponse->getData(true)['customerId'])->toBe('cus_checkout');
+});
+
+test('stripe checkout retries missing customers and contains ephemeral-key and intent failures', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'phone' => '+97699112233',
+ 'meta' => json_encode(['stripe_id' => 'cus_stale']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ ]);
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'items' => [],
+ 'events' => [],
+ ]);
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'type' => 'stripe',
+ 'config' => ['secret_key' => 'sk_test_storefront'],
+ ]);
+ $http = new class implements Stripe\HttpClient\ClientInterface {
+ public string $scenario = 'ephemeral_failure';
+ public int $ephemeralCalls = 0;
+
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_contains($absUrl, '/ephemeral_keys')) {
+ $this->ephemeralCalls++;
+ if ($this->scenario === 'ephemeral_failure') {
+ return [json_encode(['error' => ['message' => 'Ephemeral key rejected', 'type' => 'invalid_request_error']]), 400, []];
+ }
+ if ($this->scenario === 'missing_customer' && $this->ephemeralCalls === 1) {
+ return [json_encode(['error' => ['message' => 'No such customer: cus_stale', 'type' => 'invalid_request_error']]), 400, []];
+ }
+
+ return [json_encode(['id' => 'ephkey_retry', 'object' => 'ephemeral_key', 'secret' => 'eph_retry_secret']), 200, []];
+ }
+ if (str_ends_with($absUrl, '/customers')) {
+ return [json_encode(['id' => 'cus_recreated', 'object' => 'customer']), 200, []];
+ }
+ if ($this->scenario === 'intent_failure') {
+ return [json_encode(['error' => ['message' => 'Payment intent rejected', 'type' => 'invalid_request_error']]), 400, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_retry',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_retry_secret',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ };
+ Stripe\ApiRequestor::setHttpClient($http);
+ $controllerOptions = (object) ['is_pickup' => true];
+
+ $customer = Fleetbase\Storefront\Models\Customer::where('uuid', 'customer_uuid')->firstOrFail();
+ $ephemeralFailure = CheckoutController::initializeStripeCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ $controllerOptions,
+ Request::create('/checkout')
+ );
+
+ $http->scenario = 'intent_failure';
+ $http->ephemeralCalls = 0;
+ $intentFailure = CheckoutController::initializeStripeCheckout(
+ $customer->fresh(),
+ $gateway,
+ null,
+ $cart,
+ $controllerOptions,
+ Request::create('/checkout')
+ );
+
+ $http->scenario = 'missing_customer';
+ $http->ephemeralCalls = 0;
+ $retried = CheckoutController::initializeStripeCheckout(
+ $customer->fresh(),
+ $gateway,
+ null,
+ $cart,
+ $controllerOptions,
+ Request::create('/checkout')
+ );
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+
+ expect($ephemeralFailure->getData(true))->toBe(['error' => 'Error from Stripe: Ephemeral key rejected'])
+ ->and($intentFailure->getData(true))->toBe(['error' => 'Payment intent rejected'])
+ ->and($retried->getData(true)['customerId'])->toBe('cus_recreated')
+ ->and($http->ephemeralCalls)->toBe(2);
+});
+
+test('qpay checkout rejects incomplete gateway configuration before authentication or invoice calls', function () {
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'MNT',
+ 'items' => [],
+ 'events' => [],
+ ]);
+ $customer = new Contact();
+ $customer->forceFill(['uuid' => 'customer_uuid']);
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'uuid' => 'gateway_uuid',
+ 'type' => 'qpay',
+ 'config' => [],
+ ]);
+
+ $response = CheckoutController::initializeQPayCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+
+ expect($response->getData(true))->toBe(['error' => 'Gateway not configured correctly!']);
+});
+
+test('qpay checkout creates sandbox ebarimt invoices and persists invoice metadata', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'QPay store',
+ 'currency' => 'MNT',
+ 'options' => '{}',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $cart = new Cart();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'currency' => 'MNT',
+ 'items' => [
+ [
+ 'id' => 'line_one',
+ 'name' => 'Delivery box',
+ 'quantity' => 2,
+ 'price' => 1000,
+ 'subtotal' => 2000,
+ 'classification_code' => '2111100',
+ 'tax_product_code' => '319',
+ ],
+ ],
+ 'events' => [],
+ ]);
+ $customer = new Fleetbase\Storefront\Models\Customer();
+ $customer->forceFill([
+ 'uuid' => 'customer_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'phone' => '+97699112233',
+ 'meta' => [],
+ ]);
+ $gateway = new Gateway();
+ $gateway->forceFill([
+ 'uuid' => 'qpay_gateway_uuid',
+ 'type' => 'qpay',
+ 'sandbox' => true,
+ 'callback_url' => 'https://storefront.test/qpay',
+ 'config' => [
+ 'username' => 'merchant',
+ 'password' => 'secret',
+ ],
+ ]);
+ CheckoutQPayStub::$sandboxUsed = false;
+ CheckoutQPayStub::$authenticated = false;
+ CheckoutQPayStub::$invoiceKind = null;
+ CheckoutQPayStub::$invoiceArguments = [];
+
+ $response = TestableCheckoutController::initializeQPayCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) [
+ 'is_pickup' => true,
+ 'testPayment'=> 'success',
+ ],
+ Request::create('/checkout', 'POST', ['ebarimt_registration_no' => '1234567'])
+ );
+ $checkout = Checkout::query()->first();
+ $data = $response->getData(true);
+
+ expect($data['invoice']['invoice_id'])->toBe('invoice_checkout')
+ ->and($data['checkout'])->toBe($checkout->public_id)
+ ->and($data['token'])->toBe($checkout->token)
+ ->and($checkout->amount)->toBe(2000)
+ ->and($checkout->getOption('qpay_invoice_id'))->toBe('invoice_checkout')
+ ->and($customer->getMeta('ebarimt_registration_no'))->toBe('1234567')
+ ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue()
+ ->and(CheckoutQPayStub::$authenticated)->toBeTrue()
+ ->and(CheckoutQPayStub::$invoiceKind)->toBe('ebarimt')
+ ->and(CheckoutQPayStub::$invoiceArguments[0])->toBe('TEST_INVOICE')
+ ->and(CheckoutQPayStub::$invoiceArguments[7])->not->toBeEmpty();
+
+ $gateway->forceFill([
+ 'uuid' => 'qpay_gateway_uuid',
+ 'type' => 'qpay',
+ 'sandbox' => false,
+ 'callback_url' => 'https://storefront.test/qpay',
+ 'config' => [
+ 'username' => 'merchant',
+ 'password' => 'secret',
+ ],
+ ]);
+ CheckoutQPayStub::$invoiceKind = null;
+ CheckoutQPayStub::$invoiceArguments = [];
+ $simpleResponse = TestableCheckoutController::initializeQPayCheckout(
+ $customer,
+ $gateway,
+ null,
+ $cart,
+ (object) ['is_pickup' => true],
+ Request::create('/checkout')
+ );
+
+ expect($simpleResponse->getData(true)['invoice']['invoice_id'])->toBe('invoice_checkout')
+ ->and(CheckoutQPayStub::$invoiceKind)->toBe('simple')
+ ->and(CheckoutQPayStub::$invoiceArguments[0])->toBe(2000);
+});
+
+test('stripe setup and payment update endpoints reject missing gateway configuration', function () {
+ createCheckoutBoundarySchema();
+ session(['storefront_store' => 'store_uuid']);
+ $controller = new CheckoutController();
+
+ $setup = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_missing']
+ )
+ );
+ $update = $controller->updateStripePaymentIntent(Request::create(
+ '/checkout/stripe-update',
+ 'PUT'
+ ));
+
+ Model::getConnectionResolver()->connection('mysql')->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => ' ']),
+ ]);
+ $blankSetup = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_missing']
+ )
+ );
+
+ expect($setup->getData(true))->toBe(['error' => 'Stripe not setup.'])
+ ->and($update->getData(true))->toBe(['error' => 'No stripe gateway configured!'])
+ ->and($blankSetup->getData(true))->toBe(['error' => 'Gateway not configured correctly!']);
+});
+
+test('stripe setup intent returns saved payment details and contains provider failures', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => 'sk_test_storefront']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'meta' => json_encode([
+ 'stripe_id' => 'cus_checkout',
+ 'stripe_payment_method_id' => 'pm_saved',
+ ]),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session(['storefront_store' => 'store_uuid']);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_ends_with($absUrl, '/customers')) {
+ return [json_encode([
+ 'id' => 'cus_created_for_setup',
+ 'object' => 'customer',
+ ]), 200, []];
+ }
+
+ if (str_contains($absUrl, '/payment_methods/')) {
+ return [json_encode([
+ 'id' => 'pm_saved',
+ 'object' => 'payment_method',
+ 'customer' => 'cus_checkout',
+ 'type' => 'card',
+ 'card' => [
+ 'brand' => 'visa',
+ 'last4' => '4242',
+ 'exp_month' => 12,
+ 'exp_year' => 2030,
+ 'country' => 'US',
+ 'funding' => 'credit',
+ ],
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'seti_checkout',
+ 'object' => 'setup_intent',
+ 'client_secret' => 'seti_secret',
+ 'customer' => 'cus_checkout',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ });
+ $controller = new CheckoutController();
+ $success = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_abcdefgh']
+ )
+ );
+ $successData = $success->getData(true);
+ expect($successData['setupIntent'])->toBe('seti_checkout')
+ ->and($successData['clientSecret'])->toBe('seti_secret')
+ ->and($successData['customerId'])->toBe('cus_checkout')
+ ->and($successData['defaultPaymentMethod']['paymentMethodId'])->toBe('pm_saved')
+ ->and($successData['defaultPaymentMethod']['brand'])->toBe('Visa')
+ ->and($successData['defaultPaymentMethod']['last4'])->toBe('4242');
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => '{}']);
+ $createdCustomerSetup = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_abcdefgh']
+ )
+ );
+ expect($createdCustomerSetup->getData(true)['customerId'])->toBe('cus_created_for_setup');
+
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update([
+ 'meta' => json_encode([
+ 'stripe_id' => 'cus_checkout',
+ 'stripe_payment_method_id' => 'pm_unavailable',
+ ]),
+ ]);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_contains($absUrl, '/payment_methods/')) {
+ throw new RuntimeException('Saved payment method unavailable');
+ }
+
+ return [json_encode([
+ 'id' => 'seti_without_saved_method',
+ 'object' => 'setup_intent',
+ 'client_secret' => 'seti_without_saved_method_secret',
+ 'customer' => 'cus_checkout',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ });
+ $savedMethodFailure = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_abcdefgh']
+ )
+ );
+ expect($savedMethodFailure->getData(true)['defaultPaymentMethod'])->toBeNull();
+
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_contains($absUrl, '/payment_methods/')) {
+ return [json_encode([
+ 'id' => 'pm_saved',
+ 'object' => 'payment_method',
+ 'customer' => 'cus_checkout',
+ 'type' => 'card',
+ ]), 200, []];
+ }
+
+ throw new RuntimeException('Stripe setup unavailable');
+ }
+ });
+ $failure = $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_abcdefgh']
+ )
+ );
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+
+ expect($failure->getData(true))->toBe(['error' => 'Stripe setup unavailable']);
+});
+
+test('stripe payment updates validate customer identity and provider credentials before network calls', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => ' ']),
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'type' => 'customer',
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $controller = new CheckoutController();
+ $baseInput = [
+ 'cart' => 'browser-cart',
+ 'paymentIntent' => 'pi_test',
+ 'pickup' => true,
+ ];
+
+ $unknownCustomer = $controller->updateStripePaymentIntent(Request::create(
+ '/checkout/stripe-update',
+ 'PUT',
+ [...$baseInput, 'customer' => 'customer_missing']
+ ));
+ $incompleteGateway = $controller->updateStripePaymentIntent(Request::create(
+ '/checkout/stripe-update',
+ 'PUT',
+ [...$baseInput, 'customer' => 'customer_abcdefgh']
+ ));
+
+ expect($unknownCustomer->getData(true))->toBe(['error' => 'Invalid customer ID provided'])
+ ->and($incompleteGateway->getData(true))->toBe(['error' => 'Gateway not configured correctly!']);
+});
+
+test('stripe payment updates enforce modifiable states and persist refreshed checkout details', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => 'sk_test_storefront']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ 'meta' => json_encode(['stripe_id' => 'cus_checkout']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ ['id' => 'line_one', 'quantity' => 1, 'subtotal' => 1500],
+ ]),
+ 'events' => '[]',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_ends_with($absUrl, '/customers')) {
+ return [json_encode([
+ 'id' => 'cus_checkout',
+ 'object' => 'customer',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'customer' => 'cus_checkout',
+ 'status' => 'succeeded',
+ ]), 200, []];
+ }
+ });
+ $controller = new CheckoutController();
+ $input = [
+ 'customer' => 'customer_abcdefgh',
+ 'cart' => 'browser-cart',
+ 'paymentIntent' => 'pi_checkout',
+ 'pickup' => true,
+ 'tip' => '10%',
+ ];
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => '{}']);
+ $immutable = $controller->updateStripePaymentIntent(Request::create(
+ '/checkout/stripe-update',
+ 'PUT',
+ $input
+ ));
+
+ expect($immutable->getData(true))->toBe(['error' => 'PaymentIntent cannot be updated at this stage.']);
+
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_contains($absUrl, '/ephemeral_keys')) {
+ return [json_encode([
+ 'id' => 'ephkey_checkout',
+ 'object' => 'ephemeral_key',
+ 'secret' => 'eph_secret',
+ ]), 200, []];
+ }
+
+ if (strtolower($method) === 'post') {
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_updated_secret',
+ 'customer' => 'cus_checkout',
+ 'payment_method' => 'pm_new',
+ 'status' => 'requires_confirmation',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'customer' => 'cus_checkout',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ });
+ $updated = $controller->updateStripePaymentIntent(Request::create(
+ '/checkout/stripe-update',
+ 'PUT',
+ $input
+ ));
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+ $checkout = Checkout::query()->first();
+ $meta = json_decode($connection->table('contacts')->where('uuid', 'customer_uuid')->value('meta'), true);
+ $updatedData = $updated->getData(true);
+
+ expect($updatedData['paymentIntent'])->toBe('pi_checkout')
+ ->and($updatedData['clientSecret'])->toBe('pi_updated_secret')
+ ->and($updatedData['ephemeralKey'])->toBe('eph_secret')
+ ->and($updatedData['customerId'])->toBe('cus_checkout')
+ ->and($updatedData['token'])->toBe($checkout->token)
+ ->and($checkout->amount)->toBe(1650)
+ ->and($checkout->is_pickup)->toBeTrue()
+ ->and($meta['stripe_payment_method_id'])->toBe('pm_new');
+});
+
+test('stripe payment updates contain retrieve update and ephemeral-key provider failures', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => 'sk_test_storefront']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'type' => 'customer',
+ 'meta' => json_encode(['stripe_id' => 'cus_checkout']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $client = new class implements Stripe\HttpClient\ClientInterface {
+ public string $mode = 'retrieve';
+
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if ($this->mode === 'retrieve') {
+ throw new RuntimeException('retrieve unavailable');
+ }
+
+ if (str_contains($absUrl, '/ephemeral_keys')) {
+ if ($this->mode === 'ephemeral') {
+ throw new RuntimeException('ephemeral unavailable');
+ }
+
+ return [json_encode([
+ 'id' => 'ephkey_checkout', 'object' => 'ephemeral_key', 'secret' => 'eph_secret',
+ ]), 200, []];
+ }
+
+ if (strtolower($method) === 'post') {
+ if ($this->mode === 'update') {
+ throw new RuntimeException('update unavailable');
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'payment_method' => null,
+ 'status' => 'requires_confirmation',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ };
+ Stripe\ApiRequestor::setHttpClient($client);
+ $controller = new CheckoutController();
+ $request = fn () => Request::create('/checkout/stripe-update', 'PUT', [
+ 'customer' => 'customer_abcdefgh',
+ 'cart' => 'browser-cart',
+ 'paymentIntent' => 'pi_checkout',
+ 'pickup' => true,
+ ]);
+
+ $retrieveFailure = $controller->updateStripePaymentIntent($request());
+ $client->mode = 'update';
+ $updateFailure = $controller->updateStripePaymentIntent($request());
+ $client->mode = 'ephemeral';
+ $ephemeralFailure = $controller->updateStripePaymentIntent($request());
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+
+ expect($retrieveFailure->getData(true))->toBe([
+ 'error' => 'Failed to retrieve PaymentIntent: retrieve unavailable',
+ ])->and($updateFailure->getData(true))->toBe([
+ 'error' => 'Failed to update PaymentIntent: update unavailable',
+ ])->and($ephemeralFailure->getData(true))->toBe([
+ 'error' => 'Failed to create ephemeral key: ephemeral unavailable',
+ ]);
+});
+
+test('stripe authentication failures return a stable non secret gateway error for every checkout operation', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => 'sk_test_storefront']),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'phone' => '+97699112233',
+ 'meta' => '{}',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ 'storefront_currency' => 'USD',
+ ]);
+ $client = new class implements Stripe\HttpClient\ClientInterface {
+ public string $mode = 'create_customer';
+ public int $ephemeralCalls = 0;
+
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ $isCustomer = str_ends_with($absUrl, '/customers');
+ $isEphemeral = str_contains($absUrl, '/ephemeral_keys');
+ $isSetupIntent = str_contains($absUrl, '/setup_intents');
+ $isPaymentIntent = str_contains($absUrl, '/payment_intents/');
+ $isPaymentCreate = str_ends_with($absUrl, '/payment_intents');
+ $isPost = strtolower($method) === 'post';
+
+ if ($isEphemeral) {
+ $this->ephemeralCalls++;
+ }
+
+ if (
+ ($this->mode === 'create_customer' && $isCustomer)
+ || ($this->mode === 'ephemeral' && $isEphemeral)
+ || ($this->mode === 'payment_create' && $isPaymentCreate)
+ || ($this->mode === 'recreate_customer' && $isCustomer)
+ || ($this->mode === 'setup_customer' && $isCustomer)
+ || ($this->mode === 'setup_intent' && $isSetupIntent)
+ || ($this->mode === 'update_customer' && $isCustomer)
+ || ($this->mode === 'payment_retrieve' && $isPaymentIntent && !$isPost)
+ || ($this->mode === 'payment_update' && $isPaymentIntent && $isPost)
+ || ($this->mode === 'update_ephemeral' && $isEphemeral)
+ ) {
+ throw new Stripe\Exception\AuthenticationException('sk_secret_should_never_leak');
+ }
+
+ if ($this->mode === 'recreate_customer' && $isEphemeral && $this->ephemeralCalls === 1) {
+ return [json_encode([
+ 'error' => ['message' => 'No such customer: cus_stale', 'type' => 'invalid_request_error'],
+ ]), 400, []];
+ }
+
+ if ($isCustomer) {
+ return [json_encode(['id' => 'cus_checkout', 'object' => 'customer']), 200, []];
+ }
+ if ($isEphemeral) {
+ return [json_encode([
+ 'id' => 'ephkey_checkout', 'object' => 'ephemeral_key', 'secret' => 'eph_secret',
+ ]), 200, []];
+ }
+ if ($isSetupIntent) {
+ return [json_encode([
+ 'id' => 'seti_checkout', 'object' => 'setup_intent', 'client_secret' => 'seti_secret',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'pi_checkout',
+ 'object' => 'payment_intent',
+ 'client_secret' => 'pi_secret',
+ 'payment_method' => null,
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ };
+ Stripe\ApiRequestor::setHttpClient($client);
+ $gateway = Gateway::query()->firstOrFail();
+ $cart = Cart::where('unique_identifier', 'browser-cart')->firstOrFail();
+ $options = (object) ['is_pickup' => true];
+ $initialize = function (string $mode, array $meta) use ($client, $connection, $gateway, $cart, $options) {
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => json_encode($meta)]);
+ $client->mode = $mode;
+ $client->ephemeralCalls = 0;
+
+ return CheckoutController::initializeStripeCheckout(
+ Fleetbase\Storefront\Models\Customer::where('uuid', 'customer_uuid')->firstOrFail(),
+ $gateway,
+ null,
+ $cart,
+ $options,
+ Request::create('/checkout')
+ );
+ };
+ $controller = new CheckoutController();
+ $setup = function (string $mode, array $meta) use ($client, $connection, $controller) {
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => json_encode($meta)]);
+ $client->mode = $mode;
+
+ return $controller->createStripeSetupIntentForCustomer(
+ Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ '/checkout/stripe-setup',
+ 'POST',
+ ['customer' => 'customer_abcdefgh']
+ )
+ );
+ };
+ $update = function (string $mode, array $meta) use ($client, $connection, $controller) {
+ $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => json_encode($meta)]);
+ $client->mode = $mode;
+
+ return $controller->updateStripePaymentIntent(Request::create('/checkout/stripe-update', 'PUT', [
+ 'customer' => 'customer_abcdefgh',
+ 'cart' => 'browser-cart',
+ 'paymentIntent' => 'pi_checkout',
+ 'pickup' => true,
+ ]));
+ };
+
+ $responses = [
+ $initialize('create_customer', []),
+ $initialize('ephemeral', ['stripe_id' => 'cus_checkout']),
+ $initialize('payment_create', ['stripe_id' => 'cus_checkout']),
+ $initialize('recreate_customer', ['stripe_id' => 'cus_stale']),
+ $setup('setup_customer', []),
+ $setup('setup_intent', ['stripe_id' => 'cus_checkout']),
+ $update('update_customer', []),
+ $update('payment_retrieve', ['stripe_id' => 'cus_checkout']),
+ $update('payment_update', ['stripe_id' => 'cus_checkout']),
+ $update('update_ephemeral', ['stripe_id' => 'cus_checkout']),
+ ];
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+
+ foreach ($responses as $response) {
+ expect($response->getData(true))->toBe([
+ 'error' => 'Stripe gateway authentication failed. Verify the configured secret key.',
+ ])->and(json_encode($response->getData(true)))->not->toContain('sk_secret_should_never_leak');
+ }
+});
+
+test('qpay callback reports missing checkout identifiers sessions and gateways', function () {
+ createCheckoutBoundarySchema();
+ $controller = new CheckoutController();
+
+ $missingId = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST'));
+ $unknown = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_missing',
+ ]));
+ Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([
+ 'public_id' => 'checkout_public',
+ 'gateway_uuid'=> 'gateway_missing',
+ ]);
+ $missingGateway = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_public',
+ ]));
+
+ expect($missingId->getData(true))->toBe([
+ 'error' => 'CHECKOUT_ID_MISSING',
+ 'checkout' => null,
+ 'payment' => null,
+ ])->and($unknown->getData(true))->toBe([
+ 'error' => 'CHECKOUT_SESSION_NOT_FOUND',
+ 'checkout' => null,
+ 'payment' => null,
+ ])->and($missingGateway->getData(true))->toBe([
+ 'error' => 'GATEWAY_NOT_CONFIGURED',
+ 'checkout' => 'checkout_public',
+ 'payment' => null,
+ ]);
+});
+
+test('qpay callback handles invoice payment sandbox and provider failure states deterministically', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'qpay_gateway_uuid',
+ 'code' => 'qpay',
+ 'owner_uuid' => 'store_uuid',
+ 'type' => 'qpay',
+ 'sandbox' => true,
+ 'callback_url' => 'https://storefront.test/qpay',
+ 'config' => json_encode([
+ 'username' => 'merchant',
+ 'password' => 'secret',
+ ]),
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_abcdefgh',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'gateway_uuid' => 'qpay_gateway_uuid',
+ 'order_uuid' => 'order_uuid',
+ 'amount' => 2500,
+ 'currency' => 'MNT',
+ 'options' => '{}',
+ 'token' => 'checkout-token',
+ 'captured' => true,
+ ]);
+ CheckoutQPayStub::$paymentCheckResult = null;
+ CheckoutQPayStub::$failure = null;
+ CheckoutQPayStub::$sandboxUsed = false;
+ CheckoutQPayStub::$authenticated = false;
+ Fleetbase\Support\SocketCluster\SocketClusterService::$published = [];
+ $controller = new TestableCheckoutController();
+
+ $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ ]));
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $checkout->updateOption('qpay_invoice_id', 'invoice_checkout');
+ CheckoutQPayStub::$paymentCheckResult = (object) ['count' => 0, 'rows' => []];
+ $notFound = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ ]));
+ CheckoutQPayStub::$paymentCheckResult = (object) [
+ 'count' => 1,
+ 'rows' => [
+ (object) [
+ 'payment_id' => 'payment_checkout',
+ 'payment_status' => 'PAID',
+ 'payment_amount' => 2500,
+ 'payment_wallet' => 'QPay',
+ ],
+ ],
+ ];
+ $paid = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ ]));
+ CheckoutQPayStub::$failure = new RuntimeException('QPay unavailable');
+ $providerFailure = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ ]));
+ $silentProviderFailure = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ ]));
+ CheckoutQPayStub::$failure = null;
+ $sandboxSuccess = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ 'test' => 'success',
+ ]));
+ $sandboxError = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [
+ 'checkout' => 'checkout_abcdefgh',
+ 'respond' => true,
+ 'test' => 'error',
+ ]));
+
+ expect($missingInvoice->getData(true))->toBe([
+ 'error' => 'MISSING_INVOICE_ID',
+ 'checkout' => 'checkout_abcdefgh',
+ 'payment' => null,
+ ])->and($notFound->getData(true))->toBe([
+ 'error' => 'PAYMENT_NOTFOUND',
+ 'checkout' => 'checkout_abcdefgh',
+ 'payment' => null,
+ ])->and($paid->getData(true)['payment']['payment_id'])->toBe('payment_checkout')
+ ->and($providerFailure->getData(true))->toBe(['error' => 'QPay unavailable'])
+ ->and($silentProviderFailure->getData(true))->toBe([])
+ ->and($sandboxSuccess->getData(true)['payment'])->not->toBeNull()
+ ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID')
+ ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue()
+ ->and(CheckoutQPayStub::$authenticated)->toBeTrue()
+ ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toHaveCount(3);
+});
+
+test('single and multiple order capture reject invalid checkout tokens safely', function () {
+ createCheckoutBoundarySchema();
+ $controller = new CheckoutController();
+ $request = CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'checkout_invalid',
+ 'transactionDetails' => 'not-an-array',
+ ]);
+
+ $single = $controller->captureOrder($request);
+ $multiple = $controller->captureMultipleOrders($request);
+
+ expect($single->getData(true))->toBe(['error' => 'Checkout session not found.'])
+ ->and($multiple->getData(true))->toBe(['error' => 'Checkout session not found.']);
+});
+
+test('single and multiple captures return integrated-vendor provider failures before local order persistence', function () {
+ $run = function (bool $multiple): array {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'integrated_vendor_uuid',
+ 'public_id' => 'integrated_vendor_abcdefgh',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'type' => 'customer',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'integrated_vendor_uuid' => 'integrated_vendor_uuid',
+ 'amount' => 300,
+ 'currency' => 'USD',
+ 'meta' => json_encode([
+ 'origin' => $multiple ? ['place_one', 'place_two'] : ['place_one'],
+ 'destination' => 'place_destination',
+ ]),
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'vendor-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode($multiple ? [
+ ['store_id' => 'store_one', 'subtotal' => 100],
+ ['store_id' => 'store_two', 'subtotal' => 200],
+ ] : []),
+ 'events' => '[]',
+ ]);
+ if ($multiple) {
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'key' => 'network_key',
+ 'name' => 'Vendor network',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ } else {
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'key' => 'store_key',
+ 'name' => 'Vendor store',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ session([
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ }
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'network_uuid' => $multiple ? 'network_uuid' : null,
+ 'store_uuid' => $multiple ? null : 'store_uuid',
+ 'cart_uuid' => 'cart_uuid',
+ 'service_quote_uuid' => 'quote_uuid',
+ 'owner_uuid' => 'customer_uuid',
+ 'owner_type' => Contact::class,
+ 'currency' => 'USD',
+ 'is_cod' => true,
+ 'options' => '{}',
+ 'token' => 'vendor-checkout-token',
+ ]);
+ CheckoutIntegratedVendorStub::$vendorFailure = new RuntimeException('Integrated vendor rejected order');
+ $controller = new CheckoutIntegratedVendorStub();
+ $request = CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'vendor-checkout-token',
+ ]);
+ $response = $multiple
+ ? $controller->captureMultipleOrders($request)
+ : $controller->captureOrder($request);
+ CheckoutIntegratedVendorStub::$vendorFailure = null;
+
+ return $response->getData(true);
+ };
+
+ expect($run(false))->toBe(['error' => 'Integrated vendor rejected order'])
+ ->and($run(true))->toBe(['error' => 'Integrated vendor rejected order']);
+});
+
+test('single order capture persists the cash transaction payload order and checkout contract', function () {
+ createCheckoutCaptureExecutionSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'order_config_uuid' => 'network_order_config_uuid',
+ 'key' => 'network_key',
+ 'name' => 'Checkout network',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ 'alertable' => '{}',
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'order_config_uuid' => 'order_config_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ 'currency' => 'USD',
+ 'options' => json_encode([
+ 'auto_accept_orders' => true,
+ 'auto_dispatch' => true,
+ ]),
+ 'alertable' => '{}',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Checkout customer',
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'integrated_vendor_uuid',
+ 'public_id' => 'integrated_vendor_abcdefgh',
+ 'created_at'=> now(),
+ 'updated_at'=> now(),
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_coffee',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COFFEE-1',
+ 'price' => 1000,
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'product_id' => 'product_coffee',
+ 'store_id' => 'store_abcdefgh',
+ 'name' => 'Coffee',
+ 'variants' => [],
+ 'addons' => [],
+ 'quantity' => 1,
+ 'price' => 1000,
+ 'subtotal' => 1000,
+ ],
+ ]),
+ 'events' => '[]',
+ ]);
+ $connection->table('places')->insert([
+ [
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Store pickup',
+ ],
+ [
+ 'uuid' => 'destination_uuid',
+ 'public_id' => 'place_ijklmnop',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Customer destination',
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'store_location_uuid',
+ 'public_id' => 'store_location_abcdefgh',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'origin_uuid',
+ 'name' => 'Main location',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'amount' => 300,
+ 'currency' => 'USD',
+ 'integrated_vendor_uuid' => 'integrated_vendor_uuid',
+ 'meta' => json_encode([
+ 'origin' => ['place_abcdefgh'],
+ 'destination' => 'place_ijklmnop',
+ ]),
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'network_uuid' => 'network_uuid',
+ 'cart_uuid' => 'cart_uuid',
+ 'service_quote_uuid' => 'quote_uuid',
+ 'owner_uuid' => 'customer_uuid',
+ 'owner_type' => Contact::class,
+ 'amount' => 375,
+ 'currency' => 'USD',
+ 'is_cod' => true,
+ 'is_pickup' => false,
+ 'options' => json_encode(['is_pickup' => false, 'tip' => 25, 'delivery_tip' => 50]),
+ 'token' => 'checkout-token',
+ 'captured' => false,
+ ]);
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ 'company' => 'company_uuid',
+ ]);
+
+ $checkoutModel = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $captureMethod = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+ CheckoutOrderAutomationStub::$accepted = 0;
+ CheckoutOrderAutomationStub::$dispatched = 0;
+ $resource = $captureMethod->invoke(
+ new CheckoutIntegratedVendorStub(),
+ $checkoutModel,
+ ['transaction_id' => 'cash_receipt_123'],
+ 'Leave at reception'
+ );
+ Model::unsetEventDispatcher();
+
+ $transaction = $connection->table('transactions')->where('gateway_transaction_id', 'cash_receipt_123')->first();
+ $payload = $connection->table('payloads')->first();
+ $order = $connection->table('orders')->first();
+ $checkout = $connection->table('checkouts')->where('uuid', 'checkout_uuid')->first();
+ $cart = $connection->table('carts')->where('uuid', 'cart_uuid')->first();
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class)
+ ->and($transaction->gateway_transaction_id)->toBe('cash_receipt_123')
+ ->and($transaction->gateway)->toBe('cash')
+ ->and($transaction->status)->toBe('voided')
+ ->and($payload->payment_method)->toBe('cash')
+ ->and($payload->cod_amount)->toBe(1375)
+ ->and($payload->cod_currency)->toBe('USD')
+ ->and($order->payload_uuid)->toBe($payload->uuid)
+ ->and($order->notes)->toBe('Leave at reception')
+ ->and($checkout->order_uuid)->toBe($order->uuid)
+ ->and((bool) $checkout->captured)->toBeTrue()
+ ->and($cart->checkout_uuid)->toBe('checkout_uuid')
+ ->and(CheckoutOrderAutomationStub::$accepted)->toBe(1)
+ ->and(CheckoutOrderAutomationStub::$dispatched)->toBe(1);
+ expect($connection->table('transaction_items')->orderBy('id')->pluck('code')->all())
+ ->toBe(['product', 'delivery_fee', 'tip', 'delivery_tip']);
+});
+
+test('multiple order capture is idempotent after a master order has been created', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('orders')->insert([
+ 'uuid' => 'master_order_uuid',
+ 'public_id' => 'order_master',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ ['id' => 'line_one', 'store_id' => 'store_one', 'quantity' => 1, 'subtotal' => 1000],
+ ['id' => 'line_two', 'store_id' => 'store_two', 'quantity' => 1, 'subtotal' => 500],
+ ]),
+ 'events' => '[]',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'amount' => 300,
+ 'meta' => json_encode([
+ 'origin' => ['place_origin', 'place_waypoint'],
+ 'destination' => 'place_destination',
+ ]),
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'token' => 'checkout-token',
+ 'cart_uuid' => 'cart_uuid',
+ 'service_quote_uuid' => 'quote_uuid',
+ 'order_uuid' => 'master_order_uuid',
+ 'currency' => 'USD',
+ 'amount' => 1500,
+ 'is_cod' => true,
+ 'is_pickup' => true,
+ 'options' => json_encode(['is_pickup' => true]),
+ 'captured' => true,
+ ]);
+ session(['storefront_key' => null]);
+
+ $resource = (new CheckoutController())->captureMultipleOrders(
+ CaptureOrderRequest::create('/checkout/capture-multiple', 'POST', [
+ 'token' => 'checkout-token',
+ 'transactionDetails' => 'malformed-details',
+ ])
+ );
+ $connection->table('checkouts')->where('uuid', 'checkout_uuid')->update([
+ 'order_uuid' => null,
+ 'captured' => false,
+ ]);
+ $missingNetwork = (new CheckoutController())->captureMultipleOrders(
+ CaptureOrderRequest::create('/checkout/capture-multiple', 'POST', [
+ 'token' => 'checkout-token',
+ ])
+ );
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class)
+ ->and($resource->resource->uuid)->toBe('master_order_uuid')
+ ->and($connection->table('orders')->count())->toBe(1)
+ ->and($missingNetwork->getData(true))->toBe([
+ 'error' => 'No network in request to capture order!',
+ ]);
+});
+
+test('multiple order capture creates child and master logistics orders for a network checkout', function () {
+ createCheckoutCaptureExecutionSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('companies')->insert([
+ 'uuid' => 'company_uuid',
+ 'name' => 'Delivery Company',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'order_config_uuid' => 'network_order_config_uuid',
+ 'key' => 'network_key',
+ 'name' => 'Delivery network',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ 'alertable' => '{}',
+ ]);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'store_company_uuid',
+ 'order_config_uuid' => 'store_order_config_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Network store',
+ 'currency' => 'USD',
+ 'options' => json_encode([
+ 'auto_accept_orders' => true,
+ 'auto_dispatch' => true,
+ ]),
+ 'alertable' => '{}',
+ ],
+ [
+ 'uuid' => 'store_two_uuid',
+ 'public_id' => 'store_ijklmnop',
+ 'company_uuid' => 'store_two_company_uuid',
+ 'order_config_uuid' => 'store_two_order_config_uuid',
+ 'key' => 'store_two_key',
+ 'name' => 'Second network store',
+ 'currency' => 'USD',
+ 'options' => json_encode([
+ 'auto_accept_orders' => true,
+ 'auto_dispatch' => true,
+ ]),
+ 'alertable' => '{}',
+ ],
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Checkout customer',
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'integrated_vendor_uuid',
+ 'public_id' => 'integrated_vendor_abcdefgh',
+ 'created_at'=> now(),
+ 'updated_at'=> now(),
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_coffee',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COFFEE-1',
+ 'price' => 1000,
+ ]);
+ $connection->table('places')->insert([
+ [
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Store pickup',
+ ],
+ [
+ 'uuid' => 'destination_uuid',
+ 'public_id' => 'place_ijklmnop',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Customer dropoff',
+ ],
+ [
+ 'uuid' => 'origin_two_uuid',
+ 'public_id' => 'place_qrstuvwx',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Second store pickup',
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'store_location_uuid',
+ 'public_id' => 'store_location_abcdefgh',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'origin_uuid',
+ 'name' => 'Main location',
+ ],
+ [
+ 'uuid' => 'store_location_two_uuid',
+ 'public_id' => 'store_location_ijklmnop',
+ 'store_uuid' => 'store_two_uuid',
+ 'place_uuid' => 'origin_two_uuid',
+ 'name' => 'Second location',
+ ],
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'product_id' => 'product_coffee',
+ 'store_id' => 'store_abcdefgh',
+ 'store_location_id'=> 'store_location_abcdefgh',
+ 'name' => 'Coffee',
+ 'variants' => [],
+ 'addons' => [],
+ 'quantity' => 1,
+ 'price' => 1000,
+ 'subtotal' => 1000,
+ ],
+ [
+ 'product_id' => 'product_coffee',
+ 'store_id' => 'store_ijklmnop',
+ 'store_location_id'=> 'store_location_ijklmnop',
+ 'name' => 'Coffee',
+ 'variants' => [],
+ 'addons' => [],
+ 'quantity' => 1,
+ 'price' => 500,
+ 'subtotal' => 500,
+ ],
+ ]),
+ 'events' => '[]',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'quote_abcdefgh',
+ 'amount' => 300,
+ 'currency' => 'USD',
+ 'integrated_vendor_uuid' => 'integrated_vendor_uuid',
+ 'meta' => json_encode([
+ 'origin' => ['place_abcdefgh', 'place_qrstuvwx'],
+ 'destination' => 'place_ijklmnop',
+ ]),
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'network_uuid' => 'network_uuid',
+ 'cart_uuid' => 'cart_uuid',
+ 'service_quote_uuid' => 'quote_uuid',
+ 'owner_uuid' => 'customer_uuid',
+ 'owner_type' => Contact::class,
+ 'amount' => 300,
+ 'currency' => 'USD',
+ 'is_cod' => true,
+ 'is_pickup' => false,
+ 'options' => json_encode(['tip' => 25, 'delivery_tip' => 50]),
+ 'token' => 'checkout-token',
+ 'captured' => false,
+ ]);
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ 'company' => 'company_uuid',
+ ]);
+
+ CheckoutOrderAutomationStub::$accepted = 0;
+ CheckoutOrderAutomationStub::$dispatched = 0;
+ $resource = (new CheckoutIntegratedVendorStub())->captureOrder(
+ CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'checkout-token',
+ 'transactionDetails' => ['transaction_id' => 'cash_network_receipt'],
+ 'notes' => 'Network checkout',
+ ])
+ );
+ Model::unsetEventDispatcher();
+
+ $orders = $connection->table('orders')->orderBy('id')->get();
+ $checkout = $connection->table('checkouts')->where('uuid', 'checkout_uuid')->first();
+ $child = $orders->first(fn ($order) => data_get(json_decode($order->meta, true), 'is_master_order') === false);
+ $master = $orders->first(fn ($order) => data_get(json_decode($order->meta, true), 'is_master_order') === true);
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class)
+ ->and($orders)->toHaveCount(3)
+ ->and($child)->not->toBeNull()
+ ->and(json_decode($child->meta, true))->toMatchArray([
+ 'storefront_id' => 'store_abcdefgh',
+ ])
+ ->and($master)->not->toBeNull()
+ ->and(json_decode($master->meta, true))->toMatchArray([
+ 'storefront_network' => 'Delivery network',
+ ])
+ ->and((bool) $master->dispatched)->toBeTrue()
+ ->and($checkout->order_uuid)->toBe($master->uuid)
+ ->and((bool) $checkout->captured)->toBeTrue()
+ ->and($connection->table('purchase_rates')->count())->toBe(3)
+ ->and(CheckoutOrderAutomationStub::$accepted)->toBe(2)
+ ->and(CheckoutOrderAutomationStub::$dispatched)->toBe(2)
+ ->and($connection->table('transaction_items')->orderBy('id')->pluck('code')->all())
+ ->toBe(['product', 'product', 'delivery_fee', 'tip', 'delivery_tip']);
+});
+
+test('single order capture reports missing storefront and expired cart boundaries', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id'=> 'checkout_public',
+ 'token' => 'checkout_token',
+ 'is_cod' => true,
+ ]);
+ $controller = new CheckoutController();
+ $request = CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'checkout_token',
+ ]);
+
+ session(['storefront_key' => null]);
+ $missingStorefront = $controller->captureOrder($request);
+
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ 'currency' => 'USD',
+ ]);
+ session(['storefront_key' => 'store_key']);
+ $expiredCart = $controller->captureOrder($request);
+
+ expect($missingStorefront->getData(true))->toBe(['error' => 'No storefront in request to capture order!'])
+ ->and($expiredCart->getData(true))->toBe(['error' => 'Cart expired']);
+});
+
+test('single order capture is idempotent when checkout already references a completed order', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_abcdefgh',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'token' => 'checkout-token',
+ 'order_uuid' => 'order_uuid',
+ 'captured' => true,
+ 'is_cod' => true,
+ ]);
+ session([
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ 'company' => 'company_uuid',
+ ]);
+
+ $resource = (new CheckoutController())->captureOrder(
+ CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'checkout-token',
+ 'transactionDetails' => 'malformed-details',
+ ])
+ );
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class)
+ ->and($resource->resource->uuid)->toBe('order_uuid')
+ ->and($connection->table('orders')->count())->toBe(1);
+});
+
+test('single-store network capture rejects carts whose storefront can no longer be resolved', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'network_key',
+ 'name' => 'Delivery network',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'id' => 'line_one',
+ 'store_id' => 'store_missing',
+ 'store_location_id' => null,
+ 'quantity' => 1,
+ 'subtotal' => 1000,
+ ],
+ ]),
+ 'events' => '[]',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_abcdefgh',
+ 'token' => 'checkout-token',
+ 'cart_uuid' => 'cart_uuid',
+ 'currency' => 'USD',
+ 'amount' => 1000,
+ 'is_cod' => true,
+ 'is_pickup' => true,
+ 'options' => json_encode(['is_pickup' => true]),
+ 'captured' => false,
+ ]);
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ 'company' => 'company_uuid',
+ ]);
+
+ $response = (new CheckoutController())->captureOrder(
+ CaptureOrderRequest::create('/checkout/capture', 'POST', [
+ 'token' => 'checkout-token',
+ ])
+ );
+
+ expect($response->getData(true))->toBe([
+ 'error' => 'No storefront in request to capture order!',
+ ])->and($connection->table('orders')->count())->toBe(0);
+});
+
+test('checkout cart item processing creates a logistics entity with commerce metadata', function () {
+ $fleetbase = Model::getConnectionResolver()->connection('mysql');
+ $productSchema = $fleetbase->getSchemaBuilder();
+ $entitySchema = $fleetbase->getSchemaBuilder();
+ $productSchema->dropIfExists('products');
+ $entitySchema->dropIfExists('files');
+ $entitySchema->dropIfExists('entities');
+ $productSchema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $entitySchema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $entitySchema->create('entities', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('_key')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->string('internal_id')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->nullable();
+ $table->integer('sale_price')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('slug')->nullable();
+ $table->text('qr_code')->nullable();
+ $table->text('barcode')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $productSchema->getConnection()->table('products')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'public_id' => 'product_coffee',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COFFEE-1',
+ 'price' => 500,
+ 'sale_price' => 450,
+ ]);
+ session(['company' => 'company_uuid']);
+ Fleetbase\FleetOps\Models\Entity::expand(
+ 'fromStorefrontProduct',
+ Fleetbase\Storefront\Expansions\EntityExpansion::fromStorefrontProduct()
+ );
+
+ $item = (object) [
+ 'product_id' => 'product_coffee',
+ 'variants' => [['name' => 'Large']],
+ 'addons' => [['name' => 'Oat milk']],
+ 'subtotal' => 900,
+ 'quantity' => 2,
+ 'scheduled_at'=> '2026-07-27 18:00:00',
+ ];
+ $payload = (object) ['uuid' => 'payload_uuid'];
+ $customer = (object) ['uuid' => 'customer_uuid'];
+ $method = new ReflectionMethod(CheckoutController::class, 'processCartItem');
+ $method->invoke(new CheckoutController(), $item, $payload, $customer);
+
+ $entity = $fleetbase->table('entities')->first();
+
+ expect($entity->payload_uuid)->toBe('payload_uuid')
+ ->and($entity->company_uuid)->toBe('company_uuid')
+ ->and($entity->customer_uuid)->toBe('customer_uuid')
+ ->and($entity->internal_id)->toBe('product_coffee')
+ ->and($entity->name)->toBe('Coffee')
+ ->and(json_decode($entity->meta, true))->toMatchArray([
+ 'product_id' => 'product_coffee',
+ 'variants' => [['name' => 'Large']],
+ 'addons' => [['name' => 'Oat milk']],
+ 'subtotal' => 900,
+ 'quantity' => 2,
+ 'scheduled_at' => '2026-07-27 18:00:00',
+ ]);
+});
+
+test('checkout order creation is idempotent when the checkout already owns an order', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_public',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ 'order_uuid' => 'order_uuid',
+ 'captured' => true,
+ ]);
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $order = $method->invoke(
+ new CheckoutController(),
+ $checkout,
+ ['transaction_id' => 'provider_transaction']
+ );
+
+ expect($order)->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class)
+ ->and($order->uuid)->toBe('order_uuid')
+ ->and($order->public_id)->toBe('order_public')
+ ->and(Checkout::where('uuid', 'checkout_uuid')->value('order_uuid'))->toBe('order_uuid');
+});
+
+test('checkout order fallback releases its lock when capture cannot create an order', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ 'captured' => false,
+ ]);
+ session(['storefront_key' => null]);
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $controller = new CheckoutController();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $first = $method->invoke($controller, $checkout, ['transaction_id' => 'provider_transaction']);
+ $second = $method->invoke($controller, $checkout->fresh(), ['transaction_id' => 'provider_transaction']);
+
+ expect($first)->toBeNull()
+ ->and($second)->toBeNull()
+ ->and(Checkout::where('uuid', 'checkout_uuid')->value('order_uuid'))->toBeNull();
+});
+
+test('checkout order fallback contains malformed provider transaction details', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ ]);
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $result = $method->invoke(new CheckoutController(), $checkout, 'malformed-provider-payload');
+
+ expect($result)->toBeNull()
+ ->and(Checkout::where('uuid', 'checkout_uuid')->value('order_uuid'))->toBeNull();
+});
+
+test('checkout order fallback contains capture exceptions and releases its lock', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ ]);
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $result = $method->invoke(
+ new CheckoutCaptureFailureStub(),
+ $checkout,
+ ['transaction_id' => 'provider_transaction']
+ );
+
+ expect($result)->toBeNull()
+ ->and($checkout->fresh()->order_uuid)->toBeNull();
+});
+
+test('checkout order creation returns safely when another process owns the checkout lock', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ ]);
+ $previousCache = app('cache');
+ app()->instance('cache', new class {
+ public function lock($key, $seconds): object
+ {
+ return new class {
+ public function get(): bool
+ {
+ return false;
+ }
+ };
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('cache');
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $result = $method->invoke(
+ new CheckoutController(),
+ $checkout,
+ ['transaction_id' => 'provider_transaction']
+ );
+ app()->instance('cache', $previousCache);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('cache');
+
+ expect($result)->toBeNull()
+ ->and($checkout->fresh()->order_uuid)->toBeNull();
+});
+
+test('checkout order creation returns the order completed while waiting for its lock', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('orders')->insert([
+ 'uuid' => 'concurrent_order_uuid',
+ 'public_id' => 'order_concurrent',
+ ]);
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'public_id' => 'checkout_public',
+ 'token' => 'checkout_token',
+ ]);
+ $previousCache = app('cache');
+ app()->instance('cache', new class($connection) {
+ public function __construct(private $connection)
+ {
+ }
+
+ public function lock($key, $seconds): object
+ {
+ return new class($this->connection) {
+ public function __construct(private $connection)
+ {
+ }
+
+ public function get(): bool
+ {
+ $this->connection->table('checkouts')->where('uuid', 'checkout_uuid')->update([
+ 'order_uuid' => 'concurrent_order_uuid',
+ 'captured' => true,
+ ]);
+
+ return false;
+ }
+ };
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('cache');
+ $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail();
+ $method = new ReflectionMethod(CheckoutController::class, 'createOrderFromCheckout');
+
+ $result = $method->invoke(
+ new CheckoutController(),
+ $checkout,
+ ['transaction_id' => 'provider_transaction']
+ );
+ app()->instance('cache', $previousCache);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('cache');
+
+ expect($result)->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class)
+ ->and($result->uuid)->toBe('concurrent_order_uuid');
+});
diff --git a/server/tests/Unit/Http/Controllers/CheckoutCalculationTest.php b/server/tests/Unit/Http/Controllers/CheckoutCalculationTest.php
new file mode 100644
index 00000000..a0b6cbd9
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/CheckoutCalculationTest.php
@@ -0,0 +1,82 @@
+invoke(null, ...$arguments);
+}
+
+function checkoutCart(int $subtotal = 10000): Cart
+{
+ $cart = new Cart();
+ $cart->forceFill([
+ 'items' => [
+ [
+ 'subtotal' => $subtotal,
+ 'quantity' => 1,
+ ],
+ ],
+ ]);
+
+ return $cart;
+}
+
+test('checkout amount includes percentage and fixed tips plus delivery quote', function () {
+ $quote = new ServiceQuote();
+ $quote->forceFill(['amount' => 2500]);
+
+ $amount = invokeCheckoutCalculation(
+ 'calculateCheckoutAmount',
+ checkoutCart(),
+ $quote,
+ [
+ 'tip' => '10%',
+ 'delivery_tip' => 500,
+ 'is_pickup' => false,
+ ]
+ );
+
+ expect($amount)->toBe(14000);
+});
+
+test('pickup checkout excludes delivery tips and does not require a service quote', function () {
+ $amount = invokeCheckoutCalculation(
+ 'calculateCheckoutAmount',
+ checkoutCart(),
+ null,
+ (object) [
+ 'tip' => 750,
+ 'delivery_tip' => 500,
+ 'is_pickup' => true,
+ ]
+ );
+
+ expect($amount)->toBe(10750);
+});
+
+test('checkout amount supports delivery without optional gratuities', function () {
+ $quote = new ServiceQuote();
+ $quote->forceFill(['amount' => '2,500']);
+
+ $amount = invokeCheckoutCalculation(
+ 'calculateCheckoutAmount',
+ checkoutCart(),
+ $quote,
+ []
+ );
+
+ expect($amount)->toBe(12500);
+});
+
+test('checkout tip calculation handles percentage currency and empty values', function ($tip, $subtotal, $expected) {
+ expect(invokeCheckoutCalculation('calculateTipAmount', $tip, $subtotal))->toBe($expected);
+})->with([
+ 'percentage' => ['12.5%', 20000, 2500.0],
+ 'fixed integer' => [700, 20000, 700],
+ 'formatted amount' => ['1,250', 20000, 1250],
+ 'false value' => [false, 20000, 0],
+ 'null value' => [null, 20000, 0],
+]);
diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php
new file mode 100644
index 00000000..5211b889
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php
@@ -0,0 +1,1566 @@
+appleValid;
+ }
+
+ protected function verifyGoogleIdentity(string $idToken, string $clientId): ?array
+ {
+ return $this->googlePayload;
+ }
+}
+
+class CustomerIdentityProbe extends CustomerController
+{
+ public function apple(string $token): bool
+ {
+ return $this->verifyAppleIdentity($token);
+ }
+
+ public function google(string $token, string $clientId): ?array
+ {
+ return $this->verifyGoogleIdentity($token, $clientId);
+ }
+}
+
+class PhoneConflictCustomerControllerStub extends CustomerController
+{
+ public ?Fleetbase\Models\User $existingPhoneUser = null;
+
+ protected function findExistingUserByPhone(string $phone, string $excludedUserUuid): ?Fleetbase\Models\User
+ {
+ return $this->existingPhoneUser;
+ }
+}
+
+function bindUnauthenticatedCustomerRequest(array $input = []): Request
+{
+ $request = Request::create('/customer', 'POST', $input);
+ $request->setLaravelSession(new SessionStore(
+ 'customer-controller-test',
+ new ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+function createCustomerControllerUsersSchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('users');
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('password')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function createCustomerControllerContactsSchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('contacts');
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function createCustomerVerificationDeliverySchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ foreach (['verification_codes', 'users', 'stores', 'companies'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid',
+ 'order_config_uuid', 'key', 'name', 'description', 'translations',
+ 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone',
+ 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('companies', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->text('options')->nullable();
+ $table->softDeletes();
+ });
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('verification_codes', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('code')->nullable();
+ $table->string('for')->nullable();
+ $table->string('status')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([
+ 'uuid' => 'company_uuid',
+ 'options' => '{}',
+ ]);
+}
+
+function bindCustomerNotificationDispatcher(): void
+{
+ app()->instance(
+ Illuminate\Contracts\Notifications\Dispatcher::class,
+ new class implements Illuminate\Contracts\Notifications\Dispatcher {
+ public function send($notifiables, $notification)
+ {
+ }
+
+ public function sendNow($notifiables, $notification)
+ {
+ }
+ }
+ );
+ app()->instance('twilio', new class {
+ public function message(string $to, string $message, array $media = [], array $params = []): object
+ {
+ return (object) ['sid' => 'sms_test'];
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+ app()->instance('mail.manager', new class {
+ public function to($recipient): self
+ {
+ return $this;
+ }
+
+ public function send($mailable): void
+ {
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('mail.manager');
+}
+
+test('customer token protected endpoints reject unauthenticated callers', function () {
+ bindUnauthenticatedCustomerRequest();
+ $controller = new CustomerController();
+
+ $device = $controller->registerDevice(Request::create('/customer/device', 'POST'));
+ $orders = $controller->orders(Request::create('/customer/orders'));
+ $places = $controller->places(Request::create('/customer/places'));
+ $ephemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key'));
+ $setupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup'));
+ $phoneRequest = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ 'phone' => '97699112233',
+ ]));
+ $phoneVerify = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST'));
+
+ expect($device->getData(true))->toBe(['error' => 'Not authorized to register device for cutomer'])
+ ->and($orders->getData(true))->toBe(['error' => 'Not authorized to view customers orders'])
+ ->and($places->getData(true))->toBe(['error' => 'Not authorized to view customers places'])
+ ->and($ephemeralKey->getData(true))->toBe(['error' => 'Not authorized to view customers places'])
+ ->and($setupIntent->getData(true))->toBe(['error' => 'Not authorized to view customers places'])
+ ->and($phoneRequest->getData(true))->toBe(['error' => 'Not authorized to request phone verification.'])
+ ->and($phoneVerify->getData(true))->toBe(['error' => 'Not authorized to verify phone number.']);
+});
+
+test('authenticated customer endpoints register devices and scope orders and places', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['gateways', 'personal_access_tokens', 'user_devices', 'orders', 'places', 'contacts', 'verification_codes', 'users', 'stores', 'companies'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('companies', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid',
+ 'order_config_uuid', 'key', 'name', 'description', 'translations',
+ 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone',
+ 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->softDeletes();
+ });
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('phone')->nullable();
+ $table->timestamp('phone_verified_at')->nullable();
+ $table->string('email')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('verification_codes', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('code')->nullable();
+ $table->string('for')->nullable();
+ $table->string('status')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type')->nullable();
+ $table->integer('tokenable_id')->nullable();
+ $table->string('name');
+ $table->string('token');
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('user_devices', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('platform')->nullable();
+ $table->string('token')->nullable();
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->text('config')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $customerUuid = '11111111-1111-4111-8111-111111111111';
+ $connection->table('contacts')->insert([
+ 'uuid' => $customerUuid,
+ 'public_id' => 'contact_customer',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ 'meta' => json_encode(['stripe_id' => 'cus_customer']),
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('companies')->insert([
+ 'uuid' => 'company_uuid',
+ 'options' => '{}',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'name' => $customerUuid,
+ 'token' => hash('sha256', 'customer-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => 'order_visible',
+ 'public_id' => 'order_visible',
+ 'customer_uuid' => $customerUuid,
+ 'meta' => json_encode(['is_master_order' => false]),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'order_other',
+ 'public_id' => 'order_other',
+ 'customer_uuid' => 'other_customer',
+ 'meta' => json_encode([]),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ $connection->table('places')->insert([
+ [
+ 'uuid' => 'place_customer',
+ 'public_id' => 'place_customer',
+ 'owner_uuid' => $customerUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'place_other',
+ 'public_id' => 'place_other',
+ 'owner_uuid' => 'other_customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ $connection->table('gateways')->insert([
+ 'uuid' => 'stripe_gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'network_uuid',
+ 'config' => json_encode(['secret_key' => 'sk_test_storefront']),
+ ]);
+ session([
+ 'storefront_network' => 'network_uuid',
+ 'storefront_key' => 'store_key',
+ 'company' => 'company_uuid',
+ ]);
+ $boundRequest = bindUnauthenticatedCustomerRequest();
+ $boundRequest->headers->set('Customer-Token', 'customer-secret');
+ app()->instance('request', $boundRequest);
+ $controller = new CustomerController();
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ if (str_ends_with($absUrl, '/customers')) {
+ return [json_encode([
+ 'id' => 'cus_created_customer',
+ 'object' => 'customer',
+ ]), 200, []];
+ }
+
+ if (str_contains($absUrl, '/ephemeral_keys')) {
+ return [json_encode([
+ 'id' => 'ephkey_customer',
+ 'object' => 'ephemeral_key',
+ 'secret' => 'eph_customer_secret',
+ ]), 200, []];
+ }
+
+ return [json_encode([
+ 'id' => 'seti_customer',
+ 'object' => 'setup_intent',
+ 'client_secret' => 'seti_customer_secret',
+ 'customer' => 'cus_customer',
+ 'status' => 'requires_payment_method',
+ ]), 200, []];
+ }
+ });
+
+ $device = $controller->registerDevice(Request::create('/customer/device', 'POST', [
+ 'token' => 'device-token',
+ 'platform' => 'ios',
+ ]));
+ $orders = $controller->orders(Request::create('/customer/orders'));
+ $places = $controller->places(Request::create('/customer/places'));
+ $ephemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key'));
+ $setupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup'));
+ $connection->table('contacts')->where('uuid', $customerUuid)->update(['meta' => '{}']);
+ $createdEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key'));
+ $connection->table('contacts')->where('uuid', $customerUuid)->update(['meta' => '{}']);
+ $createdSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup'));
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ throw new RuntimeException('Stripe customer endpoint unavailable');
+ }
+ });
+ $failedEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key'));
+ $failedSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup'));
+ $connection->table('gateways')->delete();
+ $missingEphemeralGateway = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key'));
+ $missingSetupGateway = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup'));
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+ $closureStart = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $closureConfirm = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => '123456',
+ ]));
+ $phoneWithoutUser = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ 'phone' => '97699112233',
+ ]));
+ $verificationWithoutUser = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST', [
+ 'code' => '123456',
+ ]));
+ $connection->table('users')->insert([
+ [
+ 'uuid' => 'user_uuid',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ ],
+ [
+ 'uuid' => 'other_user_uuid',
+ 'phone' => '+97699887766',
+ 'type' => 'customer',
+ ],
+ ]);
+ bindCustomerNotificationDispatcher();
+ $phoneConflictController = new PhoneConflictCustomerControllerStub();
+ $phoneConflictController->existingPhoneUser = new Fleetbase\Models\User(['uuid' => 'other_user_uuid']);
+ $existingPhoneConflict = $phoneConflictController->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ 'phone' => '+97699887766',
+ ]));
+ $connection->table('users')->where('uuid', 'user_uuid')->update(['email' => null]);
+ $closureWithoutIdentity = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $connection->table('users')->where('uuid', 'user_uuid')->update(['email' => 'ada@example.test']);
+ $emailClosureStarted = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $connection->statement(
+ "CREATE TRIGGER fail_closure_verification_insert BEFORE INSERT ON verification_codes BEGIN SELECT RAISE(ABORT, 'closure verification failed'); END"
+ );
+ $closureDeliveryFailure = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $connection->statement('DROP TRIGGER fail_closure_verification_insert');
+ $phoneConflict = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ 'phone' => '97699112233',
+ ]));
+ $invalidPhoneCode = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST', [
+ 'code' => 'invalid-code',
+ ]));
+ $generatedCode = $connection->table('verification_codes')
+ ->where('for', 'storefront_verify_phone')
+ ->value('code');
+ $verifiedPhone = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST', [
+ 'code' => $generatedCode,
+ ]));
+ $closureStarted = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $connection->statement(
+ "CREATE TRIGGER fail_phone_verification_insert BEFORE INSERT ON verification_codes BEGIN SELECT RAISE(ABORT, 'verification insert failed'); END"
+ );
+ $phoneDeliveryFailure = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ 'phone' => '+97699112234',
+ ]));
+ $connection->statement('DROP TRIGGER fail_phone_verification_insert');
+ $closureCode = $connection->table('verification_codes')
+ ->where('for', 'storefront_account_closure')
+ ->where('meta', 'like', '%+97699112233%')
+ ->value('code');
+ $invalidClosure = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => 'invalid-code',
+ ]));
+ $connection->statement(
+ "CREATE TRIGGER fail_customer_user_delete BEFORE UPDATE ON users BEGIN SELECT RAISE(ABORT, 'user delete failed'); END"
+ );
+ $closureDeletionFailure = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => $closureCode,
+ ]));
+ $connection->statement('DROP TRIGGER fail_customer_user_delete');
+ $closed = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => $closureCode,
+ ]));
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+ expect($device->getData(true))->toHaveKey('device')
+ ->and($connection->table('user_devices')->where('token', 'device-token')->value('user_uuid'))->toBe('user_uuid')
+ ->and($orders->resource)->toHaveCount(1)
+ ->and($orders->resource->first()->uuid)->toBe('order_visible')
+ ->and($places->resource)->toHaveCount(1)
+ ->and($places->resource->first()->uuid)->toBe('place_customer')
+ ->and($ephemeralKey->getData(true))->toBe([
+ 'ephemeralKey' => 'eph_customer_secret',
+ 'customerId' => 'cus_customer',
+ ])->and($setupIntent->getData(true))->toBe([
+ 'setupIntentId' => 'seti_customer',
+ 'setupIntent' => 'seti_customer_secret',
+ 'customerId' => 'cus_customer',
+ ])->and($createdEphemeralKey->getData(true)['customerId'])->toBe('cus_created_customer')
+ ->and($createdSetupIntent->getData(true)['customerId'])->toBe('cus_created_customer')
+ ->and($failedEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable'])
+ ->and($failedSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable'])
+ ->and($missingEphemeralGateway->getData(true))->toBe(['error' => 'Stripe not setup.'])
+ ->and($missingSetupGateway->getData(true))->toBe(['error' => 'Stripe not setup.'])
+ ->and($closureStart->getData(true))->toBe(['error' => 'Customer user account not found.'])
+ ->and($closureConfirm->getData(true))->toBe(['error' => 'Customer user account not found.'])
+ ->and($phoneWithoutUser->getData(true))->toBe(['error' => 'No user associated with this customer.'])
+ ->and($verificationWithoutUser->getData(true))->toBe(['error' => 'No user associated with this customer.'])
+ ->and($closureWithoutIdentity->getData(true))->toBe([
+ 'error' => 'Customer account must have a valid email or phone number linked.',
+ ])->and($emailClosureStarted->getData(true))->toBe(['status' => 'OK'])
+ ->and($closureDeliveryFailure->getData(true))->toHaveKey('error')
+ ->and($existingPhoneConflict->getData(true))->toBe([
+ 'error' => 'This phone number is already associated with another account.',
+ ])
+ ->and($phoneConflict->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('verification_codes')->where('for', 'storefront_verify_phone')->count())->toBe(1)
+ ->and($invalidPhoneCode->getData(true))->toBe(['error' => 'Invalid verification code!'])
+ ->and($verifiedPhone)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone'))->toBe('+97699112233')
+ ->and($connection->table('contacts')->where('uuid', $customerUuid)->value('phone'))->toBe('+97699112233')
+ ->and($connection->table('verification_codes')->where('code', $generatedCode)->value('deleted_at'))->not->toBeNull()
+ ->and(json_encode($closureStarted->getData(true)))->toBe('{"status":"OK"}')
+ ->and($phoneDeliveryFailure->getData(true))->toHaveKey('error')
+ ->and($invalidClosure->getData(true))->toBe(['error' => 'Invalid verification code provided!'])
+ ->and($closureDeletionFailure->getData(true))->toHaveKey('error')
+ ->and($closed->getData(true))->toBe(['status' => 'OK'])
+ ->and($connection->table('users')->where('uuid', 'user_uuid')->value('deleted_at'))->not->toBeNull()
+ ->and($connection->table('contacts')->where('uuid', $customerUuid)->value('deleted_at'))->not->toBeNull();
+});
+
+test('customer social login endpoints validate required provider parameters', function () {
+ $controller = new CustomerController();
+
+ $apple = $controller->loginWithApple(Request::create('/customer/apple', 'POST'));
+ $google = $controller->loginWithGoogle(Request::create('/customer/google', 'POST'));
+ $invalidApple = $controller->loginWithApple(Request::create('/customer/apple', 'POST', [
+ 'identityToken' => 'malformed-token',
+ 'authorizationCode' => 'authorization-code',
+ ]));
+ $invalidGoogle = $controller->loginWithGoogle(Request::create('/customer/google', 'POST', [
+ 'idToken' => 'malformed-token',
+ 'clientId'=> 'client-id',
+ ]));
+ $socialController = new SocialCustomerControllerStub();
+ $socialController->appleValid = false;
+ $rejectedApple = $socialController->loginWithApple(Request::create('/customer/apple', 'POST', [
+ 'identityToken' => 'rejected-token',
+ 'authorizationCode' => 'authorization-code',
+ ]));
+
+ expect($apple->getStatusCode())->toBe(400)
+ ->and($apple->getData(true))->toBe(['error' => 'Missing required Apple authentication parameters.'])
+ ->and($google->getStatusCode())->toBe(400)
+ ->and($google->getData(true))->toBe(['error' => 'Missing required Google authentication parameters.'])
+ ->and($invalidApple->getStatusCode())->toBe(500)
+ ->and($rejectedApple->getData(true))->toBe(['error' => 'Apple ID authentication is not valid.'])
+ ->and($invalidGoogle->getStatusCode())->toBe(400)
+ ->and($invalidGoogle->getData(true))->toBe(['error' => 'Google Sign-In authentication is not valid.']);
+});
+
+test('customer identity verifier seams reject malformed provider tokens', function () {
+ $probe = new CustomerIdentityProbe();
+
+ try {
+ $apple = $probe->apple('malformed-token');
+ } catch (Throwable) {
+ $apple = false;
+ }
+
+ expect($apple)->toBeFalse()
+ ->and($probe->google('malformed-token', 'client-id'))->toBeNull();
+});
+
+test('facebook login links an existing customer identity and issues a local access token', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'contacts', 'users'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->default('generated_user_uuid');
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('facebook_user_id')->nullable();
+ $table->string('apple_user_id')->nullable();
+ $table->string('google_user_id')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->default('generated_contact_uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->string('user_uuid');
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type');
+ $table->string('tokenable_id');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ ]);
+ session(['company' => 'company_uuid']);
+
+ $resource = (new CustomerController())->loginWithFacebook(Request::create(
+ '/customer/facebook',
+ 'POST',
+ [
+ 'email' => 'ada@example.test',
+ 'name' => 'Ada Buyer',
+ 'facebookUserId' => 'facebook_123',
+ ]
+ ));
+ $socialController = new SocialCustomerControllerStub();
+ $apple = $socialController->loginWithApple(Request::create('/customer/apple', 'POST', [
+ 'identityToken' => 'valid-apple-token',
+ 'authorizationCode' => 'authorization-code',
+ 'email' => 'ada@example.test',
+ 'appleUserId' => 'apple_123',
+ ]));
+ $socialController->googlePayload = [
+ 'email' => 'ada@example.test',
+ 'name' => 'Ada Buyer',
+ 'sub' => 'google_123',
+ 'picture' => 'https://cdn.test/avatar.png',
+ ];
+ $google = $socialController->loginWithGoogle(Request::create('/customer/google', 'POST', [
+ 'idToken' => 'valid-google-token',
+ 'clientId'=> 'client-id',
+ ]));
+ $previousDispatcher = Model::getEventDispatcher();
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Fleetbase\Models\User::creating(function ($user) {
+ $user->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+ Fleetbase\FleetOps\Models\Contact::creating(function ($contact) {
+ $contact->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+ $newApple = $socialController->loginWithApple(Request::create('/customer/apple', 'POST', [
+ 'identityToken' => 'new-apple-token',
+ 'authorizationCode' => 'new-authorization-code',
+ 'name' => 'Apple Buyer',
+ 'phone' => '+97699110001',
+ 'appleUserId' => 'apple_new',
+ ]));
+ $newFacebook = $socialController->loginWithFacebook(Request::create('/customer/facebook', 'POST', [
+ 'name' => 'Facebook Buyer',
+ 'facebookUserId' => 'facebook_new',
+ ]));
+ $socialController->googlePayload = [
+ 'name' => 'Google Buyer',
+ 'sub' => 'google_new',
+ 'picture' => 'https://cdn.test/new-avatar.png',
+ ];
+ $newGoogle = $socialController->loginWithGoogle(Request::create('/customer/google', 'POST', [
+ 'idToken' => 'new-google-token',
+ 'clientId'=> 'client-id',
+ ]));
+ if ($previousDispatcher) {
+ Model::setEventDispatcher($previousDispatcher);
+ } else {
+ Model::unsetEventDispatcher();
+ }
+ $linkedUser = $connection->table('users')->where('uuid', 'user_uuid')->first();
+ $schema->drop('users');
+ $facebookFailure = $socialController->loginWithFacebook(Request::create('/customer/facebook', 'POST', [
+ 'facebookUserId' => 'facebook_failure',
+ ]));
+ $socialController->googlePayload = ['sub' => 'google_failure'];
+ $googleFailure = $socialController->loginWithGoogle(Request::create('/customer/google', 'POST', [
+ 'idToken' => 'google-failure-token',
+ 'clientId'=> 'client-id',
+ ]));
+ expect($resource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($resource->resource->uuid)->toBe('contact_uuid')
+ ->and($resource->resource->token)->not->toBeEmpty()
+ ->and($linkedUser->facebook_user_id)->toBe('facebook_123')
+ ->and($apple)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($linkedUser->apple_user_id)->toBe('apple_123')
+ ->and($google)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($linkedUser->google_user_id)->toBe('google_123')
+ ->and($newApple)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($newFacebook)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($newGoogle)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($facebookFailure->getData(true))->toHaveKey('error')
+ ->and($googleFailure->getData(true))->toHaveKey('error')
+ ->and($connection->table('personal_access_tokens')->count())->toBe(6);
+});
+
+test('customer creation-code requests reject malformed email identities before delivery', function () {
+ session(['storefront_key' => null]);
+ $response = (new CustomerController())->requestCustomerCreationCode(
+ VerifyCreateCustomerRequest::create('/customer/code', 'POST', [
+ 'mode' => 'email',
+ 'identity' => 'not-an-email',
+ ])
+ );
+
+ expect($response->getData(true))->toBe([
+ 'error' => 'Invalid email provided for identity',
+ ]);
+});
+
+test('customer creation-code requests generate email and SMS verification records', function () {
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ bindCustomerNotificationDispatcher();
+ $controller = new CustomerController();
+
+ $email = $controller->requestCustomerCreationCode(
+ VerifyCreateCustomerRequest::create('/customer/code', 'POST', [
+ 'mode' => 'email',
+ 'identity' => 'buyer@example.test',
+ ])
+ );
+ $sms = $controller->requestCustomerCreationCode(
+ VerifyCreateCustomerRequest::create('/customer/code', 'POST', [
+ 'mode' => 'sms',
+ 'identity' => '97699112233',
+ ])
+ );
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+
+ $records = $connection->table('verification_codes')
+ ->where('for', 'storefront_create_customer')
+ ->orderBy('id')
+ ->get();
+ app()->instance('twilio', new class {
+ public function message(string $to, string $message, array $media = [], array $params = []): object
+ {
+ throw new Twilio\Exceptions\RestException('Twilio rejected the destination', 21211, 400);
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+ $twilioFailure = $controller->requestCustomerCreationCode(
+ VerifyCreateCustomerRequest::create('/customer/code', 'POST', [
+ 'mode' => 'sms',
+ 'identity' => '97699000000',
+ ])
+ );
+ $connection->getSchemaBuilder()->drop('verification_codes');
+ $deliveryFailure = $controller->requestCustomerCreationCode(
+ VerifyCreateCustomerRequest::create('/customer/code', 'POST', [
+ 'mode' => 'email',
+ 'identity' => 'failure@example.test',
+ ])
+ );
+ expect($email->getData(true))->toBe(['status' => 'ok'])
+ ->and($sms->getData(true))->toBe(['status' => 'ok'])
+ ->and($records)->toHaveCount(2)
+ ->and(json_decode($records[0]->meta, true)['identity'])->toBe('buyer@example.test')
+ ->and(json_decode($records[1]->meta, true)['identity'])->toBe('+97699112233')
+ ->and($twilioFailure->getData(true))->toBe(['error' => 'Twilio rejected the destination'])
+ ->and($deliveryFailure->getData(true))->toHaveKey('error');
+});
+
+test('customer creation rejects unverified identities before creating users or contacts', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('verification_codes');
+ $schema->create('verification_codes', function ($table) {
+ $table->increments('id');
+ $table->string('code')->nullable();
+ $table->string('for')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ session(['storefront_key' => null]);
+
+ $response = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'buyer@example.test',
+ 'code' => 'invalid-code',
+ 'email' => 'buyer@example.test',
+ 'name' => 'Buyer',
+ ])
+ );
+
+ expect($response->getData(true))->toBe([
+ 'error' => 'Invalid verification code provided!',
+ ]);
+});
+
+test('customer creation persists a verified storefront identity and issues an access token', function () {
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'contacts', 'files'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('slug')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('uploader_uuid')->nullable();
+ $table->string('disk')->nullable();
+ $table->string('original_filename')->nullable();
+ $table->string('extension')->nullable();
+ $table->string('content_type')->nullable();
+ $table->string('path')->nullable();
+ $table->string('bucket')->nullable();
+ $table->string('type')->nullable();
+ $table->integer('file_size')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->string('user_uuid');
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type');
+ $table->string('tokenable_id');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('files')->insert([
+ 'uuid' => 'customer_photo_uuid',
+ 'public_id' => 'file_abcdefgh',
+ ]);
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'verification_uuid',
+ 'code' => '123456',
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => 'buyer@example.test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ $previousDispatcher = Model::getEventDispatcher();
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Fleetbase\Models\User::creating(function ($user) {
+ $user->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+ Fleetbase\FleetOps\Models\Contact::creating(function ($contact) {
+ $contact->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+
+ $resource = (new CustomerController())->verifyCode(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'buyer@example.test',
+ 'code' => '123456',
+ 'for' => 'storefront_create_customer',
+ 'email' => 'buyer@example.test',
+ 'phone' => '97699112233',
+ 'name' => 'Verified Buyer',
+ 'photo' => 'file_abcdefgh',
+ ])
+ );
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Fleetbase\Models\User::creating(function ($user) {
+ $user->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+ Fleetbase\FleetOps\Models\Contact::creating(function ($contact) {
+ $contact->uuid ??= (string) Illuminate\Support\Str::uuid();
+ });
+ Fleetbase\Models\File::creating(function ($file) {
+ $file->uuid ??= (string) Illuminate\Support\Str::uuid();
+ $file->public_id ??= 'file_' . Illuminate\Support\Str::lower(Illuminate\Support\Str::random(10));
+ });
+ $uploadRoot = sys_get_temp_dir() . '/storefront-customer-test-uploads';
+ config([
+ 'filesystems.default' => 'uploads',
+ 'filesystems.disks.uploads' => ['driver' => 'local', 'root' => $uploadRoot],
+ ]);
+ $configRepository = new Illuminate\Config\Repository(config());
+ app()->instance('config', $configRepository);
+ app()->instance(Illuminate\Contracts\Config\Repository::class, $configRepository);
+ app()->instance('filesystem', new Illuminate\Filesystem\FilesystemManager(app()));
+ app()->instance('responsecache', new class {
+ public function clear(): void
+ {
+ }
+ });
+ Illuminate\Support\Facades\Storage::forgetDisk('uploads');
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'base64_photo_verification_uuid',
+ 'code' => '333333',
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => 'photo@example.test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $base64PhotoResource = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'photo@example.test',
+ 'code' => '333333',
+ 'email' => 'photo@example.test',
+ 'name' => 'Photo Buyer',
+ 'photo' => base64_encode('customer-photo'),
+ ])
+ );
+ Fleetbase\FleetOps\Models\Contact::creating(function ($contact) use ($connection) {
+ if ($contact->email === 'race-recovered@example.test') {
+ $connection->table('contacts')->insert([
+ 'uuid' => 'race_recovered_contact_uuid',
+ 'company_uuid' => $contact->company_uuid,
+ 'user_uuid' => $contact->user_uuid,
+ 'name' => $contact->name,
+ 'email' => $contact->email,
+ 'type' => 'customer',
+ 'meta' => json_encode($contact->meta),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ throw new Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException('Concurrent contact creation');
+ }
+
+ if ($contact->email === 'race-failed@example.test') {
+ throw new Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException('Conflicting customer already exists');
+ }
+ });
+ foreach ([
+ ['uuid' => 'race_recovered_verification_uuid', 'code' => '444444', 'identity' => 'race-recovered@example.test'],
+ ['uuid' => 'race_failed_verification_uuid', 'code' => '555555', 'identity' => 'race-failed@example.test'],
+ ] as $verification) {
+ $connection->table('verification_codes')->insert([
+ 'uuid' => $verification['uuid'],
+ 'code' => $verification['code'],
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => $verification['identity']]),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ $raceRecovered = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'race-recovered@example.test',
+ 'code' => '444444',
+ 'email' => 'race-recovered@example.test',
+ 'name' => 'Recovered Buyer',
+ ])
+ );
+ $raceFailed = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'race-failed@example.test',
+ 'code' => '555555',
+ 'email' => 'race-failed@example.test',
+ 'name' => 'Conflicting Buyer',
+ ])
+ );
+ $connection->table('users')->insert([
+ 'uuid' => 'existing_phone_user_uuid',
+ 'company_uuid'=> 'company_uuid',
+ 'phone' => '+97699887766',
+ 'type' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'phone_verification_uuid',
+ 'code' => '654321',
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => '+97699887766']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $phoneResource = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => '97699887766',
+ 'code' => '654321',
+ 'phone' => '97699887766',
+ 'name' => 'Existing Phone Buyer',
+ ])
+ );
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'token_failure_verification_uuid',
+ 'code' => '111111',
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => 'token-failure@example.test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $schema->drop('personal_access_tokens');
+ $tokenFailure = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'token-failure@example.test',
+ 'code' => '111111',
+ 'email' => 'token-failure@example.test',
+ 'name' => 'Token Failure Buyer',
+ ])
+ );
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'contact_failure_verification_uuid',
+ 'code' => '222222',
+ 'for' => 'storefront_create_customer',
+ 'status' => 'pending',
+ 'meta' => json_encode(['identity' => 'contact-failure@example.test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->statement(
+ "CREATE TRIGGER fail_customer_contact_insert BEFORE INSERT ON contacts BEGIN SELECT RAISE(ABORT, 'contact insert failed'); END"
+ );
+ $contactFailure = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'identity' => 'contact-failure@example.test',
+ 'code' => '222222',
+ 'email' => 'contact-failure@example.test',
+ 'name' => 'Contact Failure Buyer',
+ ])
+ );
+ Illuminate\Support\Facades\Storage::disk('uploads')->deleteDirectory('');
+ if ($previousDispatcher) {
+ Model::setEventDispatcher($previousDispatcher);
+ } else {
+ Model::unsetEventDispatcher();
+ }
+
+ expect($resource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($resource->resource->email)->toBe('buyer@example.test')
+ ->and($resource->resource->phone)->toBe('+97699112233')
+ ->and($resource->resource->type)->toBe('customer')
+ ->and($resource->resource->photo_uuid)->toBe('customer_photo_uuid')
+ ->and($resource->resource->token)->not->toBeEmpty()
+ ->and($base64PhotoResource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($base64PhotoResource->resource->photo_uuid)->not->toBeNull()
+ ->and($raceRecovered)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($raceRecovered->resource->uuid)->toBe('race_recovered_contact_uuid')
+ ->and($raceFailed->getData(true))->toBe(['error' => 'Conflicting customer already exists'])
+ ->and($phoneResource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($phoneResource->resource->phone)->toBe('+97699887766')
+ ->and($tokenFailure->getData(true))->toHaveKey('error')
+ ->and($contactFailure->getData(true))->toHaveKey('error')
+ ->and($connection->table('users')->where('email', 'buyer@example.test')->value('type'))->toBe('customer')
+ ->and($connection->table('users')->where('uuid', 'existing_phone_user_uuid')->value('type'))->toBe('customer')
+ ;
+});
+
+test('customer account closure endpoints reject requests outside a storefront context', function () {
+ session(['storefront_key' => null]);
+ $controller = new CustomerController();
+
+ $start = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $confirm = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => '123456',
+ ]));
+
+ expect($start->getData(true))->toBe(['error' => 'Storefront not found.'])
+ ->and($confirm->getData(true))->toBe(['error' => 'Storefront not found.']);
+});
+
+test('customer account closure endpoints require an authenticated customer token', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid',
+ 'order_config_uuid', 'key', 'name', 'description', 'translations',
+ 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone',
+ 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Model::getConnectionResolver()->connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ session(['storefront_key' => 'store_key']);
+ bindUnauthenticatedCustomerRequest();
+ $controller = new CustomerController();
+
+ $start = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $confirm = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [
+ 'code' => '123456',
+ ]));
+
+ expect($start->getData(true))->toBe(['error' => 'Not authorized to view customers places'])
+ ->and($confirm->getData(true))->toBe(['error' => 'Not authorized to view customers places']);
+});
+
+test('customer password and phone login fail safely for unknown identities', function () {
+ createCustomerControllerUsersSchema();
+ bindUnauthenticatedCustomerRequest(['phone' => '97699112233']);
+ $controller = new CustomerController();
+
+ $password = $controller->login(Request::create('/customer/login', 'POST', [
+ 'identity' => 'missing@example.com',
+ 'password' => 'invalid-password',
+ ]));
+ $phone = $controller->loginWithPhone();
+
+ expect($password->getStatusCode())->toBe(401)
+ ->and($password->getData(true))->toBe(['error' => 'Authentication failed using password provided.'])
+ ->and($phone->getData(true))->toBe(['error' => 'No customer with this phone # found.']);
+});
+
+test('customer phone login generates a storefront-scoped SMS verification code', function () {
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'phone' => '+97699112233',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ bindUnauthenticatedCustomerRequest(['phone' => '97699112233']);
+ bindCustomerNotificationDispatcher();
+
+ $response = (new CustomerController())->loginWithPhone();
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+
+ expect($response->getData(true))->toBe(['status' => 'OK'])
+ ->and($connection->table('verification_codes')->where([
+ 'subject_uuid' => 'user_uuid',
+ 'for' => 'storefront_login',
+ ])->count())->toBe(1);
+});
+
+test('customer password login reuses the storefront contact and issues an access token', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'verification_codes', 'contacts', 'users', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid',
+ 'order_config_uuid', 'key', 'name', 'description', 'translations',
+ 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone',
+ 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->softDeletes();
+ });
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('password')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->string('user_uuid');
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type');
+ $table->string('tokenable_id');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $schema->create('verification_codes', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('for')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'password' => password_hash('correct-password', PASSWORD_BCRYPT),
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('verification_codes')->insert([
+ 'subject_uuid' => 'user_uuid',
+ 'code' => '123456',
+ 'for' => 'storefront_login',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session(['company' => 'company_uuid', 'storefront_key' => 'store_key']);
+
+ $resource = (new CustomerController())->login(Request::create('/customer/login', 'POST', [
+ 'identity' => 'ada@example.test',
+ 'password' => 'correct-password',
+ ]));
+ $invalidCode = (new CustomerController())->verifyCode(Request::create('/customer/code', 'POST', [
+ 'identity' => 'ada@example.test',
+ 'code' => '000000',
+ ]));
+ $verified = (new CustomerController())->verifyCode(Request::create('/customer/code', 'POST', [
+ 'identity' => 'ada@example.test',
+ 'code' => '123456',
+ ]));
+ $tokenCount = $connection->table('personal_access_tokens')->count();
+ $schema->drop('personal_access_tokens');
+ $loginTokenFailure = (new CustomerController())->login(Request::create('/customer/login', 'POST', [
+ 'identity' => 'ada@example.test',
+ 'password' => 'correct-password',
+ ]));
+ $verificationTokenFailure = (new CustomerController())->verifyCode(Request::create('/customer/code', 'POST', [
+ 'identity' => 'ada@example.test',
+ 'code' => '123456',
+ ]));
+
+ expect($resource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($resource->resource->uuid)->toBe('contact_uuid')
+ ->and($resource->resource->token)->not->toBeEmpty()
+ ->and($invalidCode->getData(true))->toBe(['error' => 'Invalid verification code!'])
+ ->and($verified)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($verified->resource->token)->not->toBeEmpty()
+ ->and($tokenCount)->toBe(2)
+ ->and($loginTokenFailure->getData(true))->toHaveKey('error')
+ ->and($verificationTokenFailure->getData(true))->toHaveKey('error');
+});
+
+test('customer public id aliases preserve not-found update find and delete contracts', function () {
+ createCustomerControllerContactsSchema();
+ session(['company' => null]);
+ $controller = new CustomerController();
+ $update = UpdateContactRequest::create('/customer/customer_missing', 'PATCH');
+
+ $updated = $controller->update('customer_missing', $update);
+ $found = $controller->find('customer_missing');
+ $deleted = $controller->delete('customer_missing');
+
+ expect($updated->getData(true))->toBe(['error' => 'Customer resource not found.'])
+ ->and($found->getData(true))->toBe(['error' => 'Customer resource not found.'])
+ ->and($deleted->getData(true))->toBe(['error' => 'Customer resource not found.']);
+});
+
+test('customer update find and delete persist profile location and photo removal contracts', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('files');
+ $schema->dropIfExists('places');
+ $schema->dropIfExists('contacts');
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('title')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('slug')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('uploader_uuid')->nullable();
+ $table->string('disk')->nullable();
+ $table->string('original_filename')->nullable();
+ $table->string('extension')->nullable();
+ $table->string('content_type')->nullable();
+ $table->string('path')->nullable();
+ $table->string('bucket')->nullable();
+ $table->string('type')->nullable();
+ $table->integer('file_size')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'name' => 'Old Name',
+ 'photo_uuid' => 'old_photo_uuid',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('places')->insert([
+ 'uuid' => 'place_uuid',
+ 'public_id' => 'place_public',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $connection->table('files')->insert([
+ 'uuid' => 'new_photo_uuid',
+ 'public_id' => 'file_abcdefgh',
+ ]);
+ session(['company' => 'company_uuid']);
+ $controller = new CustomerController();
+ $request = UpdateContactRequest::create('/customer/contact_public', 'PATCH', [
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'place' => 'place_public',
+ 'photo' => 'REMOVE',
+ ]);
+
+ $updated = $controller->update('contact_public', $request);
+ $found = $controller->find('customer_public');
+ $photoUpdated = $controller->update(
+ 'contact_public',
+ UpdateContactRequest::create('/customer/contact_public', 'PATCH', [
+ 'photo' => 'file_abcdefgh',
+ ])
+ );
+ $previousDispatcher = Model::getEventDispatcher();
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Fleetbase\Models\File::creating(function ($file) {
+ $file->uuid ??= (string) Illuminate\Support\Str::uuid();
+ $file->public_id ??= 'file_' . Illuminate\Support\Str::lower(Illuminate\Support\Str::random(10));
+ });
+ $base64PhotoUpdated = $controller->update(
+ 'contact_public',
+ UpdateContactRequest::create('/customer/contact_public', 'PATCH', [
+ 'photo' => base64_encode('updated-customer-photo'),
+ ])
+ );
+ if ($previousDispatcher) {
+ Model::setEventDispatcher($previousDispatcher);
+ } else {
+ Model::unsetEventDispatcher();
+ }
+ $connection->statement(
+ "CREATE TRIGGER fail_customer_contact_update BEFORE UPDATE ON contacts BEGIN SELECT RAISE(ABORT, 'contact update failed'); END"
+ );
+ $updateFailure = $controller->update(
+ 'contact_public',
+ UpdateContactRequest::create('/customer/contact_public', 'PATCH', [
+ 'name' => 'Rejected update',
+ ])
+ );
+ $connection->statement('DROP TRIGGER fail_customer_contact_update');
+
+ expect($updated)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($updated->resource->name)->toBe('Ada Buyer')
+ ->and($updated->resource->type)->toBe('customer')
+ ->and($updated->resource->place_uuid)->toBe('place_uuid')
+ ->and($updated->resource->photo_uuid)->toBeNull()
+ ->and($found)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($found->resource->uuid)->toBe('contact_uuid')
+ ->and($photoUpdated->resource->photo_uuid)->toBe('new_photo_uuid')
+ ->and($base64PhotoUpdated->resource->photo_uuid)->not->toBeNull()
+ ->and($updateFailure->getData(true))->toHaveKey('error');
+
+ $deleted = $controller->delete('customer_public');
+
+ expect($deleted)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\DeletedResource::class)
+ ->and($connection->table('contacts')->where('uuid', 'contact_uuid')->value('deleted_at'))->not->toBeNull();
+});
+
+test('customer phone normalization adds one international prefix', function () {
+ expect(CustomerController::phone('97699112233'))->toBe('+97699112233')
+ ->and(CustomerController::phone('+97699112233'))->toBe('+97699112233');
+
+ bindUnauthenticatedCustomerRequest(['phone' => '15551234567']);
+
+ expect(CustomerController::phone())->toBe('+15551234567');
+});
+
+test('customer code verification rejects identities without a user account', function () {
+ createCustomerControllerUsersSchema();
+ bindUnauthenticatedCustomerRequest();
+
+ $response = (new CustomerController())->verifyCode(Request::create('/customer/code', 'POST', [
+ 'identity' => 'missing@example.com',
+ 'code' => '123456',
+ ]));
+
+ expect($response->getData(true))->toBe(['error' => 'Unable to verify code.']);
+});
+
+test('customer query scopes records to customer type and active company', function () {
+ createCustomerControllerContactsSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('contacts')->insert([
+ [
+ 'uuid' => 'customer_one',
+ 'public_id' => 'contact_one',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ ],
+ [
+ 'uuid' => 'vendor_one',
+ 'public_id' => 'contact_vendor',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'vendor',
+ ],
+ [
+ 'uuid' => 'customer_other',
+ 'public_id' => 'contact_other',
+ 'company_uuid' => 'other_company',
+ 'type' => 'customer',
+ ],
+ ]);
+ session(['company' => 'company_uuid']);
+ $request = bindUnauthenticatedCustomerRequest();
+
+ $resource = (new CustomerController())->query($request);
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('customer_one');
+});
diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php
new file mode 100644
index 00000000..52dc71b4
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php
@@ -0,0 +1,490 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['checkouts', 'reviews', 'files', 'categories', 'places', 'stores', 'store_locations', 'network_stores', 'networks'] as $table) {
+ $schema->dropIfExists($table);
+ }
+
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->timestamp('created_at')->nullable();
+ $table->timestamp('updated_at')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->text('tags')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->text('location')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('reviews', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->integer('rating')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('checkouts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+test('network stores endpoint rejects storefront store contexts', function () {
+ session(['storefront_store' => 'store_uuid']);
+
+ $response = (new NetworkController())->stores(Request::create('/network/stores'));
+
+ expect($response->getData(true))->toBe(['error' => 'Stores cannot have stores!']);
+});
+
+test('network stores endpoint returns an empty collection for a network without stores', function () {
+ createNetworkApiControllerSchema();
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $request = Request::create('/network/stores', 'GET', [
+ 'ids' => 'store_one,store_two',
+ 'exclude' => 'store_three',
+ 'limit' => 10,
+ 'offset' => 2,
+ ]);
+
+ $resource = (new NetworkController())->stores($request);
+
+ expect($resource->resource)->toBeEmpty();
+});
+
+test('network stores endpoint applies membership category tag id and exclusion filters', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_abcdefgh',
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_match_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Local Grocery',
+ 'tags' => json_encode(['food', 'local']),
+ 'created_at' => '2026-01-01 00:00:00',
+ ],
+ [
+ 'uuid' => 'store_excluded_uuid',
+ 'public_id' => 'store_excluded',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Excluded Grocery',
+ 'tags' => json_encode(['food', 'local']),
+ 'created_at' => '2026-01-02 00:00:00',
+ ],
+ [
+ 'uuid' => 'store_other_company_uuid',
+ 'public_id' => 'store_other',
+ 'company_uuid' => 'other_company',
+ 'name' => 'Other Grocery',
+ 'tags' => json_encode(['food', 'local']),
+ 'created_at' => '2026-01-03 00:00:00',
+ ],
+ [
+ 'uuid' => 'store_uncategorized_one',
+ 'public_id' => 'store_uncategorized_one',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Uncategorized One',
+ 'tags' => json_encode([]),
+ 'created_at' => '2026-01-04 00:00:00',
+ ],
+ [
+ 'uuid' => 'store_uncategorized_two',
+ 'public_id' => 'store_uncategorized_two',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Uncategorized Two',
+ 'tags' => json_encode([]),
+ 'created_at' => '2026-01-05 00:00:00',
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'location_match', 'store_uuid' => 'store_match_uuid'],
+ ['uuid' => 'location_excluded', 'store_uuid' => 'store_excluded_uuid'],
+ ['uuid' => 'location_other', 'store_uuid' => 'store_other_company_uuid'],
+ ['uuid' => 'location_uncategorized_one', 'store_uuid' => 'store_uncategorized_one'],
+ ['uuid' => 'location_uncategorized_two', 'store_uuid' => 'store_uncategorized_two'],
+ ]);
+ $connection->table('network_stores')->insert([
+ [
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_match_uuid',
+ 'category_uuid' => 'category_uuid',
+ ],
+ [
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uncategorized_one',
+ 'category_uuid' => null,
+ ],
+ [
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uncategorized_two',
+ 'category_uuid' => null,
+ ],
+ [
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_excluded_uuid',
+ 'category_uuid' => 'category_uuid',
+ ],
+ [
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_other_company_uuid',
+ 'category_uuid' => 'category_uuid',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $request = Request::create('/network/stores', 'GET', [
+ 'category' => 'category_abcdefgh',
+ 'tagged' => 'food,local',
+ 'ids' => 'store_abcdefgh,store_excluded',
+ 'exclude' => 'store_excluded',
+ 'limit' => 10,
+ 'offset' => 0,
+ ]);
+
+ $resource = (new NetworkController())->stores($request);
+ $uncategorized = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'without_category' => true,
+ 'ids' => ['store_uncategorized_one', 'store_uncategorized_two'],
+ 'limit' => 1,
+ 'offset' => 1,
+ ]));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('store_match_uuid')
+ ->and($uncategorized->resource)->toHaveCount(1)
+ ->and($uncategorized->resource->first()->uuid)->toBe('store_uncategorized_two');
+});
+
+test('network stores endpoint sorts popular stores by checkout count', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_popular_uuid',
+ 'public_id' => 'store_popular',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Popular store',
+ ],
+ [
+ 'uuid' => 'store_quiet_uuid',
+ 'public_id' => 'store_quiet',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Quiet store',
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'location_popular', 'store_uuid' => 'store_popular_uuid'],
+ ['uuid' => 'location_quiet', 'store_uuid' => 'store_quiet_uuid'],
+ ]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_popular_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_quiet_uuid'],
+ ]);
+ $connection->table('checkouts')->insert([
+ ['uuid' => 'checkout_one', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'checkout_two', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'checkout_three', 'store_uuid' => 'store_quiet_uuid', 'created_at' => now(), 'updated_at' => now()],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'sort' => 'popular',
+ ]));
+ $trending = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'sort' => 'trending',
+ ]));
+
+ expect($resource->resource->pluck('uuid')->all())->toBe([
+ 'store_popular_uuid',
+ 'store_quiet_uuid',
+ ])->and($resource->resource->first()->checkouts_count)->toBe(2)
+ ->and($trending->resource->first()->uuid)->toBe('store_popular_uuid');
+});
+
+test('network stores endpoint honors rating and age sort contracts', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_high_uuid',
+ 'public_id' => 'store_high',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'High rated newer store',
+ 'created_at' => '2026-02-01 00:00:00',
+ ],
+ [
+ 'uuid' => 'store_low_uuid',
+ 'public_id' => 'store_low',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Low rated older store',
+ 'created_at' => '2026-01-01 00:00:00',
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'location_high', 'store_uuid' => 'store_high_uuid'],
+ ['uuid' => 'location_low', 'store_uuid' => 'store_low_uuid'],
+ ]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_high_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_low_uuid'],
+ ]);
+ $connection->table('reviews')->insert([
+ ['uuid' => 'review_high', 'subject_uuid' => 'store_high_uuid', 'rating' => 5],
+ ['uuid' => 'review_low', 'subject_uuid' => 'store_low_uuid', 'rating' => 1],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $controller = new NetworkController();
+
+ $highest = $controller->stores(Request::create('/network/stores?sort=highest_rated', 'GET', ['sort' => 'highest_rated']));
+ $lowest = $controller->stores(Request::create('/network/stores?sort=lowest_rated', 'GET', ['sort' => 'lowest_rated']));
+ $newest = $controller->stores(Request::create('/network/stores?sort=newest', 'GET', ['sort' => 'newest']));
+ $oldest = $controller->stores(Request::create('/network/stores?sort=oldest', 'GET', ['sort' => 'oldest']));
+
+ expect($highest->resource->first()->uuid)->toBe('store_high_uuid')
+ ->and($lowest->resource->first()->uuid)->toBe('store_low_uuid')
+ ->and($newest->resource->first()->uuid)->toBe('store_high_uuid')
+ ->and($oldest->resource->first()->uuid)->toBe('store_low_uuid');
+});
+
+test('network stores endpoint sorts stores by their nearest persisted location', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_near_uuid',
+ 'public_id' => 'store_near',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Near store',
+ ],
+ [
+ 'uuid' => 'store_far_uuid',
+ 'public_id' => 'store_far',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Far store',
+ ],
+ ]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_near_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_far_uuid'],
+ ]);
+ $connection->table('places')->insert([
+ ['uuid' => 'place_near_uuid', 'location' => pack('V', 0) . pack('CVee', 1, 1, 106.9177, 47.9185)],
+ ['uuid' => 'place_near_second_uuid', 'location' => pack('V', 0) . pack('CVee', 1, 1, 106.9300, 47.9300)],
+ ['uuid' => 'place_far_uuid', 'location' => pack('V', 0) . pack('CVee', 1, 1, 107.2000, 48.1000)],
+ ['uuid' => 'place_far_second_uuid', 'location' => pack('V', 0) . pack('CVee', 1, 1, 107.3000, 48.2000)],
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'location_near_uuid',
+ 'store_uuid' => 'store_near_uuid',
+ 'place_uuid' => 'place_near_uuid',
+ ],
+ [
+ 'uuid' => 'location_far_uuid',
+ 'store_uuid' => 'store_far_uuid',
+ 'place_uuid' => 'place_far_uuid',
+ ],
+ [
+ 'uuid' => 'location_near_second_uuid',
+ 'store_uuid' => 'store_near_uuid',
+ 'place_uuid' => 'place_near_second_uuid',
+ ],
+ [
+ 'uuid' => 'location_far_second_uuid',
+ 'store_uuid' => 'store_far_uuid',
+ 'place_uuid' => 'place_far_second_uuid',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'sort' => 'nearest',
+ 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176],
+ ]));
+
+ expect($resource->resource->pluck('uuid')->all())->toBe([
+ 'store_near_uuid',
+ 'store_far_uuid',
+ ])->and($resource->resource->first()->locations->first()->distance)->toBeLessThan(
+ $resource->resource->last()->locations->first()->distance
+ );
+});
+
+test('network tags endpoint returns unique tags across assigned stores', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_one', 'tags' => json_encode(['food', 'local'])],
+ ['uuid' => 'store_two', 'tags' => json_encode(['local', 'delivery'])],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'location_one', 'store_uuid' => 'store_one'],
+ ['uuid' => 'location_two', 'store_uuid' => 'store_two'],
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two'],
+ ]);
+ session(['storefront_network' => 'network_uuid']);
+
+ $response = (new NetworkController())->tags(Request::create('/network/tags'));
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toBe(['food', 'local', 'delivery']);
+});
+
+test('network store locations apply store membership search and identifier filters', function () {
+ createNetworkApiControllerSchema();
+ config(['database.connections.mysql.database' => 'main']);
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_match_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Local Grocery',
+ 'tags' => json_encode(['food', 'local']),
+ ],
+ [
+ 'uuid' => 'store_other_uuid',
+ 'public_id' => 'store_other',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Other Shop',
+ 'tags' => json_encode(['retail']),
+ ],
+ ]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_match_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_other_uuid'],
+ ]);
+ $connection->table('places')->insert([
+ ['uuid' => 'place_match_uuid', 'location' => null],
+ ['uuid' => 'place_other_uuid', 'location' => null],
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'location_match_uuid',
+ 'public_id' => 'location_abcdefgh',
+ 'store_uuid' => 'store_match_uuid',
+ 'place_uuid' => 'place_match_uuid',
+ 'tags' => json_encode(['pickup']),
+ ],
+ [
+ 'uuid' => 'location_other_uuid',
+ 'public_id' => 'location_other',
+ 'store_uuid' => 'store_other_uuid',
+ 'place_uuid' => 'place_other_uuid',
+ 'tags' => json_encode([]),
+ ],
+ ]);
+ session(['storefront_network' => 'network_uuid']);
+ $request = Request::create('/network/store-locations', 'GET', [
+ 'ids' => 'location_abcdefgh,location_other',
+ 'exclude' => 'location_other',
+ 'tagged' => 'food,local',
+ 'query' => 'Local',
+ 'with_store' => true,
+ 'limit' => 5,
+ 'offset' => 0,
+ ]);
+
+ $resource = (new NetworkController())->storeLocations($request);
+ $offsetResource = (new NetworkController())->storeLocations(Request::create(
+ '/network/store-locations',
+ 'GET',
+ [
+ 'ids' => ['location_abcdefgh', 'location_other'],
+ 'limit' => 1,
+ 'offset' => 1,
+ ]
+ ));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('location_match_uuid')
+ ->and($resource->resource->first()->relationLoaded('store'))->toBeTrue()
+ ->and($offsetResource->resource)->toHaveCount(1)
+ ->and($offsetResource->resource->first()->uuid)->toBe('location_other_uuid');
+});
diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php
new file mode 100644
index 00000000..01200d36
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php
@@ -0,0 +1,692 @@
+all();
+ }
+}
+
+function createProductApiControllerSchema(): void
+{
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['products', 'product_addon_categories', 'product_variant_options', 'product_variants', 'product_addons', 'network_stores', 'networks', 'stores', 'files', 'categories'] as $table) {
+ $schema->dropIfExists($table);
+ }
+
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->default(0);
+ $table->string('currency')->nullable();
+ $table->integer('sale_price')->default(0);
+ $table->boolean('is_service')->default(false);
+ $table->boolean('is_bookable')->default(false);
+ $table->boolean('is_available')->default(true);
+ $table->boolean('is_on_sale')->default(false);
+ $table->boolean('is_recommended')->default(false);
+ $table->boolean('can_pickup')->default(false);
+ $table->text('youtube_urls')->nullable();
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_addon_categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('product_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->text('excluded_addons')->nullable();
+ $table->integer('max_selectable')->nullable();
+ $table->boolean('is_required')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_addons', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->default(0);
+ $table->boolean('is_on_sale')->default(false);
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_variants', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('product_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->boolean('is_required')->default(false);
+ $table->boolean('is_multiselect')->default(false);
+ $table->integer('min')->default(0);
+ $table->integer('max')->default(1);
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('product_variant_options', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('product_variant_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->integer('additional_cost')->default(0);
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('owner_type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('slug')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+function productApiRequest(string $uri = '/products', string $method = 'GET', array $input = []): Request
+{
+ $request = Request::create($uri, $method, $input);
+ $request->setLaravelSession(new SessionStore(
+ 'product-api-controller-test',
+ new ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+test('product query returns only available products for the active storefront store', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'available_uuid',
+ 'public_id' => 'product_available',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Available',
+ 'is_available' => true,
+ ],
+ [
+ 'uuid' => 'unavailable_uuid',
+ 'public_id' => 'product_unavailable',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Unavailable',
+ 'is_available' => false,
+ ],
+ [
+ 'uuid' => 'other_store_uuid',
+ 'public_id' => 'product_other',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'other_store',
+ 'name' => 'Other store',
+ 'is_available' => true,
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+
+ $resource = (new ProductController())->query(productApiRequest(
+ '/products?store=store_uuid',
+ 'GET',
+ ['store' => 'store_uuid']
+ ));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('available_uuid')
+ ->and($resource->resource->first()->relationLoaded('addonCategories'))->toBeTrue()
+ ->and($resource->resource->first()->relationLoaded('variants'))->toBeTrue()
+ ->and($resource->resource->first()->relationLoaded('files'))->toBeTrue();
+});
+
+test('product query returns available products assigned through the active network', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ ['uuid' => 'network_store_uuid', 'public_id' => 'store_network', 'name' => 'Network store'],
+ ['uuid' => 'outside_store_uuid', 'public_id' => 'store_outside', 'name' => 'Outside store'],
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'name' => 'Delivery network',
+ ]);
+ $connection->table('network_stores')->insert([
+ 'uuid' => 'network_store_pivot_uuid',
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'network_store_uuid',
+ ]);
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'network_product_uuid',
+ 'public_id' => 'product_network',
+ 'store_uuid' => 'network_store_uuid',
+ 'name' => 'Network product',
+ 'is_available' => true,
+ ],
+ [
+ 'uuid' => 'outside_product_uuid',
+ 'public_id' => 'product_outside',
+ 'store_uuid' => 'outside_store_uuid',
+ 'name' => 'Outside product',
+ 'is_available' => true,
+ ],
+ ]);
+ session([
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new ProductController())->query(productApiRequest(
+ '/products?network=network_uuid',
+ 'GET',
+ ['network' => 'network_uuid']
+ ));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('network_product_uuid');
+});
+
+test('product creation normalizes commerce fields and session ownership', function () {
+ createProductApiControllerSchema();
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_currency' => 'MNT',
+ ]);
+ $request = CreateProductRequest::create('/products', 'POST', [
+ 'name' => 'Delivery box',
+ 'description' => 'Reusable insulated box',
+ 'tags' => 'shipping,insulated',
+ 'youtube_urls' => 'https://example.test/demo',
+ 'price' => '12,500',
+ 'sale_price' => '10,000',
+ 'is_available' => true,
+ 'status' => 'published',
+ ]);
+
+ $resource = (new ProductController())->create($request);
+ $product = $resource->resource;
+
+ expect($product->exists)->toBeTrue()
+ ->and($product->company_uuid)->toBe('company_uuid')
+ ->and($product->created_by_uuid)->toBe('user_uuid')
+ ->and($product->store_uuid)->toBe('store_uuid')
+ ->and($product->currency)->toBe('MNT')
+ ->and($product->price)->toBe(12500)
+ ->and($product->sale_price)->toBe(10000)
+ ->and($product->tags)->toBe(['shipping', 'insulated'])
+ ->and($product->youtube_urls)->toBe(['https://example.test/demo']);
+});
+
+test('product creation persists category addons variants and option contracts', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_currency' => 'USD',
+ ]);
+ $request = CreateProductRequest::create('/products', 'POST', [
+ 'name' => 'Custom meal',
+ 'price' => '25.00',
+ 'category' => [
+ 'name' => 'Meals',
+ 'description' => 'Prepared meals',
+ 'tags' => ['prepared'],
+ ],
+ 'addon_categories' => [
+ [
+ 'name' => 'Extras',
+ 'description' => 'Optional extras',
+ 'tags' => ['food'],
+ 'addons' => [
+ 'Napkins',
+ [
+ 'name' => 'Sauce',
+ 'price' => '2.50',
+ 'sale_price' => '1.50',
+ 'is_on_sale' => true,
+ ],
+ ],
+ 'excluded_addons' => ['addon_hidden'],
+ 'max_selectable' => 2,
+ 'is_required' => true,
+ ],
+ ],
+ 'variants' => [
+ [
+ 'name' => 'Size',
+ 'description' => 'Meal size',
+ 'meta' => ['display' => 'buttons'],
+ 'is_required' => true,
+ 'is_multiselect' => false,
+ 'min' => 1,
+ 'max' => 1,
+ 'options' => [
+ 'Regular',
+ [
+ 'name' => 'Large',
+ 'description' => 'Large portion',
+ 'additional_cost' => '4.00',
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ $product = (new ProductController())->create($request)->resource;
+ $category = $connection->table('categories')
+ ->where('for', 'storefront_product')
+ ->where('name', 'Meals')
+ ->first();
+ $addonCategory = $connection->table('categories')
+ ->where('for', 'storefront_product_addon')
+ ->where('name', 'Extras')
+ ->first();
+ $variant = $connection->table('product_variants')->where('product_uuid', $product->uuid)->first();
+
+ expect($product->category_uuid)->toBe($category->uuid)
+ ->and($category->owner_uuid)->toBe('store_uuid')
+ ->and($connection->table('product_addons')->where('category_uuid', $addonCategory->uuid)->pluck('name')->all())
+ ->toEqualCanonicalizing(['Napkins', 'Sauce'])
+ ->and($connection->table('product_addons')->where('name', 'Sauce')->value('price'))->toBe(250)
+ ->and($connection->table('product_addon_categories')->where('product_uuid', $product->uuid)->value('category_uuid'))->toBe($addonCategory->uuid)
+ ->and($connection->table('product_addon_categories')->where('product_uuid', $product->uuid)->value('is_required'))->toBe(1)
+ ->and($variant->name)->toBe('Size')
+ ->and($variant->is_required)->toBe(1)
+ ->and($connection->table('product_variant_options')->where('product_variant_uuid', $variant->uuid)->pluck('name')->all())
+ ->toEqualCanonicalizing(['Regular', 'Large'])
+ ->and($connection->table('product_variant_options')->where('name', 'Large')->value('additional_cost'))->toBe(400);
+});
+
+test('product creation resolves an existing category and update creates a replacement category', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('categories')->insert([
+ [
+ 'uuid' => 'existing_category_uuid',
+ 'public_id' => 'category_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Existing category',
+ 'for' => 'storefront_product',
+ ],
+ [
+ 'uuid' => 'existing_addon_category_uuid',
+ 'public_id' => 'addon_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => null,
+ 'name' => 'Existing extras',
+ 'for' => 'storefront_product_addon',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_currency' => 'USD',
+ 'storefront_key' => 'store_key',
+ ]);
+ $created = (new ProductController())->create(CreateProductRequest::create('/products', 'POST', [
+ 'name' => 'Categorized product',
+ 'price' => 1000,
+ 'category' => 'category_abcdefgh',
+ 'addon_categories' => ['addon_abcdefgh'],
+ ]))->resource;
+ $updateRequest = ProductApiUpdateRequest::create('/products/' . $created->public_id, 'PATCH', [
+ 'name' => 'Categorized product',
+ 'price' => 1000,
+ 'category' => [
+ 'name' => 'Replacement category',
+ 'description' => 'Created while updating',
+ 'tags' => ['replacement'],
+ ],
+ ]);
+ $updateRequest->setLaravelSession(new SessionStore(
+ 'product-api-category-update-test',
+ new ArraySessionHandler(120)
+ ));
+ $updateRequest->session()->put([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ app()->instance('request', $updateRequest);
+
+ $updated = (new ProductController())->update($created->public_id, $updateRequest)->resource;
+ $replacement = $connection->table('categories')->where('name', 'Replacement category')->first();
+
+ expect($created->category_uuid)->toBe('existing_category_uuid')
+ ->and($connection->table('product_addon_categories')->where('product_uuid', $created->uuid)->value('category_uuid'))->toBe('existing_addon_category_uuid')
+ ->and($replacement->owner_uuid)->toBe('store_uuid')
+ ->and($replacement->for)->toBe('storefront_product')
+ ->and($updated->category_uuid)->toBe($replacement->uuid);
+});
+
+test('product query applies a known storefront category filter', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_drinks',
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'store_uuid',
+ 'for' => 'storefront_product',
+ ]);
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'drink_uuid',
+ 'public_id' => 'product_drink',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'category_uuid',
+ 'name' => 'Drink',
+ 'is_available' => true,
+ ],
+ [
+ 'uuid' => 'food_uuid',
+ 'public_id' => 'product_food',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'other_category',
+ 'name' => 'Food',
+ 'is_available' => true,
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+
+ $resource = (new ProductController())->query(productApiRequest(
+ '/products',
+ 'GET',
+ ['category' => 'category_drinks']
+ ));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('drink_uuid');
+});
+
+test('product update find and delete expose stable not-found responses', function () {
+ createProductApiControllerSchema();
+ session(['company' => null]);
+ $controller = new ProductController();
+
+ $update = $controller->update(
+ 'product_missing',
+ UpdateProductRequest::create('/products/product_missing', 'PATCH')
+ );
+ $find = $controller->find('product_missing');
+ $delete = $controller->delete('product_missing');
+
+ expect($update->getStatusCode())->toBe(404)
+ ->and($update->getData(true))->toBe(['error' => 'Product not found.'])
+ ->and($find->getData(true))->toBe(['error' => 'Product resource not found.'])
+ ->and($delete->getData(true))->toBe(['error' => 'Product resource not found.']);
+});
+
+test('product update find and delete preserve resource and category contracts', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_updated_uuid',
+ 'public_id' => 'category_updated',
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Updated category',
+ 'for' => 'storefront_product',
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_existing',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => null,
+ 'name' => 'Original name',
+ 'price' => 1000,
+ 'currency' => 'USD',
+ 'is_available' => true,
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ $request = ProductApiUpdateRequest::create('/products/product_existing', 'PATCH', [
+ 'name' => 'Updated name',
+ 'price' => 3000,
+ 'sale_price' => 2500,
+ 'tags' => ['updated'],
+ 'youtube_urls' => ['https://example.test/updated'],
+ 'category' => 'category_updated',
+ 'status' => 'active',
+ ]);
+ $request->setLaravelSession(new SessionStore(
+ 'product-api-update-test',
+ new ArraySessionHandler(120)
+ ));
+ $request->session()->put([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ app()->instance('request', $request);
+ $controller = new ProductController();
+
+ $updated = $controller->update('product_existing', $request);
+ $found = $controller->find('product_existing');
+ $deleted = $controller->delete('product_existing');
+
+ expect($updated->resource->name)->toBe('Updated name')
+ ->and($updated->resource->category_uuid)->toBe('category_updated_uuid')
+ ->and($updated->resource->price)->toBe(3000)
+ ->and($updated->resource->sale_price)->toBe(2500)
+ ->and($updated->resource->tags)->toBe(['updated'])
+ ->and($found->resource->uuid)->toBe('product_uuid')
+ ->and($deleted->resource->uuid)->toBe('product_uuid')
+ ->and($connection->table('products')->where('uuid', 'product_uuid')->value('deleted_at'))->not->toBeNull();
+});
+
+test('product update resolves public ids while synchronizing addon and variant relationships', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $productUuid = '11111111-1111-4111-8111-111111111111';
+ $addonCategoryUuid = '22222222-2222-4222-8222-222222222222';
+ $pivotUuid = '33333333-3333-4333-8333-333333333333';
+ $variantUuid = '44444444-4444-4444-8444-444444444444';
+ $optionUuid = '55555555-5555-4555-8555-555555555555';
+ $connection->table('products')->insert([
+ 'uuid' => $productUuid,
+ 'public_id' => 'product_relations',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Configurable meal',
+ 'price' => 1000,
+ 'currency' => 'USD',
+ 'is_available' => true,
+ ]);
+ $connection->table('categories')->insert([
+ 'uuid' => $addonCategoryUuid,
+ 'public_id' => 'addon_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Extras',
+ 'for' => 'storefront_product_addon',
+ ]);
+ $connection->table('product_addon_categories')->insert([
+ 'uuid' => $pivotUuid,
+ 'public_id' => 'pac_abcdefgh',
+ 'product_uuid' => $productUuid,
+ 'category_uuid' => $addonCategoryUuid,
+ 'max_selectable' => 1,
+ ]);
+ $connection->table('product_variants')->insert([
+ 'uuid' => $variantUuid,
+ 'public_id' => 'variant_abcdefgh',
+ 'product_uuid' => $productUuid,
+ 'name' => 'Size',
+ 'min' => 1,
+ 'max' => 1,
+ ]);
+ $connection->table('product_variant_options')->insert([
+ 'uuid' => $optionUuid,
+ 'public_id' => 'variantoption_abcdefgh',
+ 'product_variant_uuid' => $variantUuid,
+ 'name' => 'Large',
+ 'additional_cost' => 100,
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ $request = ProductApiUpdateRequest::create('/products/product_relations', 'PATCH', [
+ 'name' => 'Configurable meal',
+ 'price' => 1000,
+ 'addon_categories' => [
+ [
+ 'id' => 'pac_abcdefgh',
+ 'category' => 'addon_abcdefgh',
+ 'excluded_addons' => ['addon_sold_out'],
+ 'max_selectable' => 3,
+ 'is_required' => true,
+ ],
+ ],
+ 'variants' => [
+ [
+ 'id' => 'variant_abcdefgh',
+ 'name' => 'Portion size',
+ 'description' => 'Updated size',
+ 'is_multiselect' => false,
+ 'is_required' => true,
+ 'min' => 1,
+ 'max' => 2,
+ 'options' => [
+ [
+ 'id' => 'variantoption_abcdefgh',
+ 'name' => 'Extra large',
+ 'additional_cost' => '3.50',
+ ],
+ [
+ 'name' => 'Family',
+ 'additional_cost' => '7.00',
+ ],
+ ],
+ ],
+ [
+ 'name' => 'Temperature',
+ 'is_multiselect' => false,
+ 'is_required' => false,
+ 'options' => [
+ ['name' => 'Hot', 'additional_cost' => 0],
+ ],
+ ],
+ ],
+ ]);
+ $request->setLaravelSession(new SessionStore(
+ 'product-api-relations-test',
+ new ArraySessionHandler(120)
+ ));
+ $request->session()->put([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ app()->instance('request', $request);
+
+ $resource = (new ProductController())->update('product_relations', $request);
+
+ expect($resource->resource->uuid)->toBe($productUuid)
+ ->and($connection->table('product_addon_categories')->where('uuid', $pivotUuid)->value('max_selectable'))->toBe(3)
+ ->and($connection->table('product_addon_categories')->where('uuid', $pivotUuid)->value('is_required'))->toBe(1)
+ ->and($connection->table('product_variants')->where('uuid', $variantUuid)->value('name'))->toBe('Portion size')
+ ->and($connection->table('product_variant_options')->where('uuid', $optionUuid)->value('name'))->toBe('Extra large')
+ ->and($connection->table('product_variant_options')->where('uuid', $optionUuid)->value('additional_cost'))->toBe(350)
+ ->and($connection->table('product_variant_options')->where('product_variant_uuid', $variantUuid)->pluck('name')->all())
+ ->toEqualCanonicalizing(['Extra large', 'Family'])
+ ->and($connection->table('product_variants')->where('product_uuid', $productUuid)->pluck('name')->all())
+ ->toEqualCanonicalizing(['Portion size', 'Temperature']);
+});
diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php
new file mode 100644
index 00000000..0215e0fd
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php
@@ -0,0 +1,535 @@
+calls['add'] = func_get_args();
+
+ return (object) ['id' => 'line_item'];
+ }
+
+ public function updateItem($cartItem, $quantity = 1, $variants = [], $addons = [], $scheduledAt = null)
+ {
+ $this->calls['update'] = func_get_args();
+
+ return (object) ['id' => $cartItem];
+ }
+
+ public function remove($cartItem)
+ {
+ $this->calls['remove'] = func_get_args();
+
+ return $this;
+ }
+
+ public function empty()
+ {
+ $this->calls['empty'] = true;
+
+ return $this;
+ }
+
+ public function delete()
+ {
+ $this->calls['delete'] = true;
+
+ return true;
+ }
+}
+
+class TestableCartController extends CartController
+{
+ public ?Fleetbase\Storefront\Models\Cart $cart = null;
+ public array $retrievals = [];
+
+ protected function retrieveCart(?string $uniqueId, bool $create = false): Fleetbase\Storefront\Models\Cart
+ {
+ $this->retrievals[] = [$uniqueId, $create];
+
+ return $this->cart;
+ }
+}
+
+function createPublicCartControllerSchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('carts');
+ $schema->create('carts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('checkout_uuid')->nullable();
+ $table->string('customer_id')->nullable();
+ $table->string('unique_identifier')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('discount_code')->nullable();
+ $table->text('items')->nullable();
+ $table->text('events')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+test('cart retrieval creates a fresh cart when no identifier is supplied', function () {
+ createPublicCartControllerSchema();
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_currency' => 'USD',
+ 'customer_id' => 'customer_public',
+ ]);
+
+ $resource = (new CartController())->retrieve(null, Request::create('/cart'));
+ $cart = $resource->resource;
+
+ expect($cart->exists)->toBeTrue()
+ ->and($cart->company_uuid)->toBe('company_uuid')
+ ->and($cart->currency)->toBe('USD')
+ ->and($cart->customer_id)->toBe('customer_public')
+ ->and($cart->items)->toBe([])
+ ->and($cart->events)->toBe([]);
+});
+
+test('cart retrieval reuses a caller identifier and excludes checked out carts', function () {
+ createPublicCartControllerSchema();
+ session(['company' => 'company_uuid']);
+ $controller = new CartController();
+
+ $first = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
+ $second = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
+ $first->forceFill(['checkout_uuid' => 'checkout_uuid'])->save();
+ $replacement = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
+ $cartRows = Model::getConnectionResolver()->connection('mysql')->table('carts')
+ ->where('unique_identifier', 'browser-session-1')
+ ->get();
+
+ expect($second->unique_identifier)->toBe('browser-session-1')
+ ->and($replacement->unique_identifier)->toBe('browser-session-1');
+ expect($cartRows)->toHaveCount(2)
+ ->and($cartRows->whereNotNull('checkout_uuid'))->toHaveCount(1)
+ ->and($cartRows->whereNull('checkout_uuid'))->toHaveCount(1);
+});
+
+test('cart controller reports invalid product and line item operations', function () {
+ createPublicCartControllerSchema();
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $controller = new CartController();
+
+ $add = $controller->add(
+ 'browser-session-2',
+ 'missing_product',
+ Request::create('/cart/items', 'POST', [
+ 'quantity' => 2,
+ 'variants' => [],
+ 'addons' => [],
+ 'store_location' => 'location_public',
+ 'scheduled_at' => '2026-07-27 12:00:00',
+ ])
+ );
+ $update = $controller->update(
+ 'browser-session-2',
+ 'missing_item',
+ Request::create('/cart/items', 'PATCH', [
+ 'quantity' => 3,
+ ])
+ );
+ $remove = $controller->remove(
+ 'browser-session-2',
+ 'missing_item',
+ Request::create('/cart/items', 'DELETE')
+ );
+
+ expect($add->getStatusCode())->toBe(400)
+ ->and($add->getData(true))->toHaveKey('error')
+ ->and($update->getStatusCode())->toBe(400)
+ ->and($update->getData(true))->toHaveKey('error')
+ ->and($remove->getStatusCode())->toBe(400)
+ ->and($remove->getData(true))->toHaveKey('error');
+});
+
+test('cart controller delegates successful item and lifecycle operations with request options', function () {
+ $cart = new CartControllerOperationStub();
+ $cart->forceFill([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_abcdefgh',
+ 'unique_identifier' => 'browser-session',
+ 'currency' => 'USD',
+ 'items' => [],
+ 'events' => [],
+ ]);
+ $controller = new TestableCartController();
+ $controller->cart = $cart;
+
+ $retrieved = $controller->retrieve('browser-session', Request::create('/cart'));
+ $added = $controller->add(
+ 'browser-session',
+ 'product_abcdefgh',
+ Request::create('/cart/items', 'POST', [
+ 'quantity' => 2,
+ 'variants' => [['name' => 'Large']],
+ 'addons' => [['name' => 'Insurance']],
+ 'store_location' => 'store_location_abcdefgh',
+ 'scheduled_at' => '2026-07-28 09:00:00',
+ ])
+ );
+ $updated = $controller->update(
+ 'browser-session',
+ 'line_item_abcdefgh',
+ Request::create('/cart/items', 'PATCH', [
+ 'quantity' => 3,
+ 'variants' => [['name' => 'Small']],
+ 'addons' => [],
+ 'scheduled_at' => '2026-07-29 10:00:00',
+ ])
+ );
+ $removed = $controller->remove(
+ 'browser-session',
+ 'line_item_abcdefgh',
+ Request::create('/cart/items', 'DELETE')
+ );
+ $emptied = $controller->empty('browser-session');
+ $deleted = $controller->delete('browser-session');
+
+ expect($retrieved->resource)->toBe($cart)
+ ->and($added->resource)->toBe($cart)
+ ->and($updated->resource)->toBe($cart)
+ ->and($removed->resource)->toBe($cart)
+ ->and($emptied->resource)->toBe($cart)
+ ->and($deleted->getData(true))->toBe([])
+ ->and($controller->retrievals)->toBe([
+ ['browser-session', true],
+ ['browser-session', false],
+ ['browser-session', false],
+ ['browser-session', false],
+ ['browser-session', false],
+ ['browser-session', false],
+ ])
+ ->and($cart->calls['add'])->toBe([
+ 'product_abcdefgh',
+ 2,
+ [['name' => 'Large']],
+ [['name' => 'Insurance']],
+ 'store_location_abcdefgh',
+ '2026-07-28 09:00:00',
+ ])
+ ->and($cart->calls['update'])->toBe([
+ 'line_item_abcdefgh',
+ 3,
+ [['name' => 'Small']],
+ [],
+ '2026-07-29 10:00:00',
+ ])
+ ->and($cart->calls['remove'])->toBe(['line_item_abcdefgh'])
+ ->and($cart->calls['empty'])->toBeTrue()
+ ->and($cart->calls['delete'])->toBeTrue();
+});
+
+test('internal product entity creation returns an empty resource collection for no products', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $resource = (new ProductController())->createEntities(Request::create('/products/entities', 'POST', [
+ 'products' => [],
+ ]));
+
+ expect($resource->resource)->toBeEmpty();
+});
+
+test('internal product entity creation persists logistics entities for selected products', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['entities', 'files', 'products'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->default(0);
+ $table->text('meta')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('entities', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->string('internal_id')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->nullable();
+ $table->integer('sale_price')->nullable();
+ $table->string('type')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'product_one_uuid',
+ 'public_id' => 'product_abcdefgh',
+ 'name' => 'Box',
+ 'description' => 'Reusable box',
+ 'currency' => 'USD',
+ 'sku' => 'BOX-1',
+ 'price' => 1000,
+ 'sale_price' => 800,
+ 'meta' => '{}',
+ ],
+ [
+ 'uuid' => 'product_other_uuid',
+ 'public_id' => 'product_other',
+ 'name' => 'Not selected',
+ 'description' => null,
+ 'currency' => null,
+ 'sku' => null,
+ 'price' => 0,
+ 'sale_price' => 0,
+ 'meta' => '{}',
+ ],
+ ]);
+ session(['company' => 'company_uuid']);
+ Fleetbase\FleetOps\Models\Entity::expand(
+ 'fromStorefrontProduct',
+ Fleetbase\Storefront\Expansions\EntityExpansion::fromStorefrontProduct()
+ );
+
+ $resource = (new ProductController())->createEntities(Request::create('/products/entities', 'POST', [
+ 'products' => ['product_one_uuid'],
+ ]));
+ $entity = $connection->table('entities')->first();
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($entity->company_uuid)->toBe('company_uuid')
+ ->and($entity->internal_id)->toBe('product_abcdefgh')
+ ->and($entity->name)->toBe('Box')
+ ->and($entity->type)->toBe('storefront-product')
+ ->and(json_decode($entity->meta, true)['product_id'])->toBe('product_abcdefgh');
+});
+
+test('product import with no uploaded files returns no products', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('files');
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('path')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $request = Request::create('/products/import', 'POST', ['files' => []]);
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'product-import-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+
+ $response = (new ProductController())->processImports($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toBe([]);
+});
+
+test('product import rejects unsupported files and contains spreadsheet reader failures', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('files');
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('path')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('files')->insert([
+ ['uuid' => 'file_pdf', 'path' => 'imports/products.pdf'],
+ ['uuid' => 'file_csv', 'path' => 'imports/products.csv'],
+ ]);
+ $session = new Illuminate\Session\Store(
+ 'product-import-validation',
+ new Illuminate\Session\ArraySessionHandler(120)
+ );
+ $controller = new ProductController();
+ $invalidRequest = Request::create('/products/import', 'POST', ['files' => ['file_pdf']]);
+ $invalidRequest->setLaravelSession($session);
+ $readerFailureRequest = Request::create('/products/import', 'POST', ['files' => ['file_csv']]);
+ $readerFailureRequest->setLaravelSession($session);
+
+ $invalid = $controller->processImports($invalidRequest);
+ $readerFailure = $controller->processImports($readerFailureRequest);
+
+ expect($invalid->getData(true))->toBe([
+ 'error' => 'Invalid file uploaded, must be one of the following: csv, tsv, xls, xlsx',
+ ])->and($readerFailure->getData(true))->toBe([
+ 'error' => 'Invalid file, unable to proccess.',
+ ]);
+});
+
+test('product import persists normalized spreadsheet rows and skips empty entries', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['products', 'files', 'categories', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('path')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('sku')->nullable();
+ $table->text('tags')->nullable();
+ $table->text('youtube_urls')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->default(0);
+ $table->string('currency')->nullable();
+ $table->boolean('is_service')->default(false);
+ $table->boolean('is_bookable')->default(false);
+ $table->boolean('is_on_sale')->default(false);
+ $table->boolean('is_available')->default(true);
+ $table->boolean('is_recommended')->default(false);
+ $table->boolean('can_pickup')->default(false);
+ $table->string('status')->nullable();
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('files')->insert([
+ 'uuid' => 'file_csv',
+ 'path' => 'imports/products.csv',
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'currency' => 'USD',
+ ]);
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_abcdefgh',
+ ]);
+ Maatwebsite\Excel\Facades\Excel::swap(new class {
+ public function toArray($import, string $path, string $disk): array
+ {
+ return [[
+ [],
+ [
+ 'product_name' => 'Insulated box',
+ 'details' => 'Reusable shipping box',
+ 'tags' => 'shipping,reusable',
+ 'stock_number' => 'BOX-1',
+ 'cost' => '12.50',
+ 'sale_cost' => '10.00',
+ 'is_service' => false,
+ 'bookable' => true,
+ 'on_sale' => true,
+ 'available' => true,
+ 'recommended' => true,
+ 'can_pickup' => true,
+ 'youtube' => 'https://example.test/video',
+ 'primary_image' => 'https://example.test/box.png',
+ ],
+ ]];
+ }
+ });
+ $request = Request::create('/products/import', 'POST', [
+ 'files' => ['file_csv'],
+ 'store' => 'store_uuid',
+ 'category' => 'category_uuid',
+ 'disk' => 'local',
+ ]);
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'product-import-success',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ $request->session()->put([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ ]);
+
+ $response = (new ProductController())->processImports($request);
+ $product = Fleetbase\Storefront\Models\Product::query()->first();
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toHaveCount(1)
+ ->and($product->name)->toBe('Insulated box')
+ ->and($product->description)->toBe('Reusable shipping box')
+ ->and($product->company_uuid)->toBe('company_uuid')
+ ->and($product->created_by_uuid)->toBe('user_uuid')
+ ->and($product->store_uuid)->toBe('store_uuid')
+ ->and($product->category_uuid)->toBe('category_uuid')
+ ->and($product->currency)->toBe('USD')
+ ->and($product->tags)->toBe(['shipping', 'reusable'])
+ ->and($product->youtube_urls)->toBe(['https://example.test/video'])
+ ->and($product->status)->toBe('published');
+});
+
+test('public category query returns an empty collection without storefront context', function () {
+ session([
+ 'storefront_store' => null,
+ 'storefront_network' => null,
+ ]);
+
+ $resource = (new CategoryController())->query(Request::create('/categories'));
+
+ expect($resource->resource)->toBeEmpty();
+});
diff --git a/server/tests/Unit/Http/Controllers/RemainingZeroCoverageContractsTest.php b/server/tests/Unit/Http/Controllers/RemainingZeroCoverageContractsTest.php
new file mode 100644
index 00000000..abab8b4a
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/RemainingZeroCoverageContractsTest.php
@@ -0,0 +1,140 @@
+connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('product_addons');
+ $schema->dropIfExists('categories');
+ $controller = new AddonCategoryController();
+ $create = $controller->createRecord(Request::create('/addon-categories', 'POST', [
+ 'addonCategory' => ['name' => 'Unavailable category'],
+ ]));
+ $update = $controller->updateRecord(Request::create('/addon-categories/missing', 'PATCH', [
+ 'addonCategory' => ['name' => 'Unavailable category'],
+ ]), 'missing');
+
+ expect($create->getStatusCode())->toBe(400)
+ ->and($create->getData(true))->toHaveKey('error')
+ ->and($update->getStatusCode())->toBe(400)
+ ->and($update->getData(true))->toHaveKey('error');
+});
+
+test('addon category controller persists category details and addon changes', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('product_addons');
+ $schema->dropIfExists('categories');
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('description')->nullable();
+ $table->string('for')->nullable();
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('product_addons', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->default(0);
+ $table->boolean('is_on_sale')->nullable();
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ session(['user' => 'user_uuid', 'company' => 'company_uuid']);
+
+ $controller = new AddonCategoryController();
+ $create = Request::create('/addon-categories', 'POST', [
+ 'addonCategory' => [
+ 'name' => 'Packaging',
+ 'addons' => [['name' => 'Gift wrap', 'price' => '$5.00']],
+ ],
+ ]);
+ $created = $controller->createRecord($create);
+ $record = $connection->table('categories')->first();
+
+ expect($created)->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
+ ->and($record->name)->toBe('Packaging')
+ ->and($connection->table('product_addons')->value('name'))->toBe('Gift wrap');
+
+ $connection->table('categories')->where('id', $record->id)->update(['public_id' => 'category_packaging']);
+
+ $update = Request::create('/addon-categories/category_packaging', 'PATCH', [
+ 'addonCategory' => [
+ 'name' => 'Premium packaging',
+ 'addons' => [['name' => 'Ribbon', 'price' => 250]],
+ ],
+ ]);
+ $updated = $controller->updateRecord($update, 'category_packaging');
+ $internalCreate = Request::create('/int/v1/storefront/addon-categories', 'POST', [
+ 'addonCategory' => ['name' => 'Internal packaging', 'addons' => []],
+ ]);
+ $internalCreate->setRouteResolver(fn () => new class {
+ public array $action = [];
+
+ public function uri(): string
+ {
+ return 'int/v1/storefront/addon-categories';
+ }
+ });
+ $internalCreated = $controller->createRecord($internalCreate);
+ $internalUpdate = Request::create('/int/v1/storefront/addon-categories/category_packaging', 'PATCH', [
+ 'addonCategory' => ['name' => 'Internal premium packaging', 'addons' => []],
+ ]);
+ $internalUpdate->setRouteResolver(fn () => new class {
+ public array $action = [];
+
+ public function uri(): string
+ {
+ return 'int/v1/storefront/addon-categories/{id}';
+ }
+ });
+ $internalUpdated = $controller->updateRecord($internalUpdate, 'category_packaging');
+
+ expect($updated)->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
+ ->and($connection->table('categories')->where('public_id', 'category_packaging')->value('name'))
+ ->toBe('Internal premium packaging')
+ ->and($connection->table('product_addons')->where('name', 'Ribbon')->value('price'))->toBe(250)
+ ->and($internalCreated)->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
+ ->and($internalUpdated)->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
+ ->and($connection->table('categories')->where('public_id', 'category_packaging')->value('name'))
+ ->toBe('Internal premium packaging');
+});
+
+test('nearby order command scopes its candidates to dispatched enroute storefront orders', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('status')->nullable();
+ $table->string('type')->nullable();
+ $table->boolean('dispatched')->default(false);
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('orders')->insert([
+ ['status' => 'created', 'type' => 'storefront', 'dispatched' => false],
+ ['status' => 'driver_enroute', 'type' => 'other', 'dispatched' => true],
+ ]);
+
+ $orders = (new NotifyStorefrontOrderNearby())->getActiveStorefrontOrders();
+
+ expect($orders)->toBeEmpty();
+});
diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php
new file mode 100644
index 00000000..99391a85
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php
@@ -0,0 +1,1253 @@
+posted = compact('path', 'params', 'options');
+
+ return $this->response;
+ }
+
+ public function getPayment(string $invoiceId)
+ {
+ $this->posted['invoice_id'] = $invoiceId;
+
+ return $this->payment;
+ }
+
+ public function useSandbox()
+ {
+ $this->sandboxed = true;
+
+ return $this;
+ }
+
+ public function setAuthToken(?string $accessToken = null): QPay
+ {
+ $this->authenticated = true;
+
+ return $this;
+ }
+}
+
+class CustomerOrderControllerStub extends OrderController
+{
+ public ReceiptQPayStub $qpay;
+ public bool $failReceipt = false;
+
+ protected function createQpay(?string $username, ?string $password, ?string $callbackUrl): QPay
+ {
+ return $this->qpay;
+ }
+
+ protected function createEbarimtReceipt(QPay $qpay, $payment, string $receiverType, ?string $receiver = null)
+ {
+ if ($this->failReceipt) {
+ return response()->apiError('Receipt provider failed.');
+ }
+
+ return parent::createEbarimtReceipt($qpay, $payment, $receiverType, $receiver);
+ }
+}
+
+class CustomerPickupControllerStub extends OrderController
+{
+ protected function patchOrderConfig(Fleetbase\FleetOps\Models\Order $order)
+ {
+ return null;
+ }
+
+ protected function updateOrderStatus(Fleetbase\FleetOps\Models\Order $order, string $status)
+ {
+ $order->status = $status;
+
+ return $order;
+ }
+}
+
+class CustomerOrderControllerProbe extends OrderController
+{
+ public function patch(Fleetbase\FleetOps\Models\Order $order)
+ {
+ return $this->patchOrderConfig($order);
+ }
+
+ public function updateStatus(Fleetbase\FleetOps\Models\Order $order, string $status)
+ {
+ return $this->updateOrderStatus($order, $status);
+ }
+
+ public function qpay(?string $username, ?string $password, ?string $callbackUrl): QPay
+ {
+ return $this->createQpay($username, $password, $callbackUrl);
+ }
+}
+
+class OrderActionStub extends Fleetbase\FleetOps\Models\Order
+{
+ public array $calls = [];
+ public bool $pickup = false;
+ public bool $failStatus = false;
+
+ public function isMeta($key): bool
+ {
+ return $key === 'is_pickup' && $this->pickup;
+ }
+
+ public function firstDispatchWithActivity(): Fleetbase\FleetOps\Models\Order
+ {
+ $this->calls[] = 'first_dispatch';
+
+ return $this;
+ }
+
+ public function setStatus(?string $status, $andSave = true)
+ {
+ if ($this->failStatus) {
+ throw new RuntimeException('status failure');
+ }
+
+ $this->status = $status;
+ $this->calls[] = 'set:' . $status;
+
+ return $this;
+ }
+
+ public function insertActivity(Fleetbase\FleetOps\Flow\Activity $activity, $location = [], $proof = null): string
+ {
+ $this->calls[] = 'activity:' . $activity->code;
+
+ return 'tracking_status_uuid';
+ }
+
+ public function getLastLocation()
+ {
+ return ['lat' => 47.9, 'lng' => 106.9];
+ }
+
+ public function updateStatus($code = null)
+ {
+ $this->status = $code;
+ $this->calls[] = 'update_status:' . $code;
+
+ return $this;
+ }
+
+ public function update(array $attributes = [], array $options = [])
+ {
+ $this->forceFill($attributes);
+ $this->calls[] = 'update';
+
+ return true;
+ }
+
+ public function assignDriver($driver, $silent = false)
+ {
+ $this->calls[] = 'driver:' . $driver;
+
+ return $this;
+ }
+
+ public function dispatchWithActivity(): Fleetbase\FleetOps\Models\Order
+ {
+ $this->calls[] = 'dispatch';
+
+ return $this;
+ }
+
+ public function save(array $options = [])
+ {
+ $this->calls[] = 'save';
+
+ return true;
+ }
+}
+
+class InternalOrderActionControllerStub extends InternalOrderController
+{
+ public ?Fleetbase\FleetOps\Models\Order $order = null;
+ public bool $notificationFails = false;
+ public int $patches = 0;
+
+ protected function findOrderRecord($id): Fleetbase\FleetOps\Models\Order
+ {
+ if (!$this->order) {
+ throw new Illuminate\Database\Eloquent\ModelNotFoundException();
+ }
+
+ return $this->order;
+ }
+
+ protected function findOrderForAction($uuid, array $relations = []): ?Fleetbase\FleetOps\Models\Order
+ {
+ return $this->order;
+ }
+
+ protected function patchOrderConfig(Fleetbase\FleetOps\Models\Order $order)
+ {
+ $this->patches++;
+
+ return new class {
+ public function getActivityByCode(string $code): Fleetbase\FleetOps\Flow\Activity
+ {
+ return new Fleetbase\FleetOps\Flow\Activity(['code' => $code]);
+ }
+ };
+ }
+
+ protected function createAcceptedActivity($orderConfig)
+ {
+ return new Fleetbase\FleetOps\Flow\Activity(['code' => 'preparing']);
+ }
+
+ protected function notifyOrderAccepted(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ if ($this->notificationFails) {
+ throw new RuntimeException('notification failure');
+ }
+
+ $order->calls[] = 'notified';
+ }
+
+ protected function orderResponse(Fleetbase\FleetOps\Models\Order $order): array
+ {
+ return ['status' => $order->status, 'order' => $order->public_id];
+ }
+}
+
+class InternalOrderControllerProbe extends InternalOrderController
+{
+ public function patch(Fleetbase\FleetOps\Models\Order $order)
+ {
+ return $this->patchOrderConfig($order);
+ }
+
+ public function acceptedActivity($orderConfig = null)
+ {
+ return $this->createAcceptedActivity($orderConfig);
+ }
+
+ public function notifyAccepted(Fleetbase\FleetOps\Models\Order $order): void
+ {
+ $this->notifyOrderAccepted($order);
+ }
+
+ public function responseFor(Fleetbase\FleetOps\Models\Order $order): array
+ {
+ return $this->orderResponse($order);
+ }
+}
+
+class NotifiableOrderCustomerStub extends Model
+{
+ public bool $notified = false;
+
+ public function notify($notification): void
+ {
+ $this->notified = $notification instanceof Fleetbase\Storefront\Notifications\StorefrontOrderAccepted;
+ }
+}
+
+class DriverAssignmentStub extends Model
+{
+ public bool $unassigned = false;
+
+ public function unassignCurrentOrder(): void
+ {
+ $this->unassigned = true;
+ }
+}
+
+function createReviewControllerSchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('reviews');
+ $schema->create('reviews', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->integer('rating')->nullable();
+ $table->text('content')->nullable();
+ $table->boolean('rejected')->default(false);
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) {
+ $request = Request::create('/reviews');
+
+ (new ReviewController())->applySort($request, $sort);
+
+ if ($expected === null) {
+ expect($request->has('sort'))->toBeFalse();
+ } else {
+ expect($request->only(['sort', 'sort_direction']))->toBe($expected);
+ }
+})->with([
+ 'highest' => ['highest rated', ['sort' => 'rating', 'sort_direction' => 'desc']],
+ 'lowest' => ['lowest', ['sort' => 'rating', 'sort_direction' => 'asc']],
+ 'newest' => ['newest first', ['sort' => 'created_at', 'sort_direction' => 'desc']],
+ 'oldest' => ['oldest', ['sort' => 'created_at', 'sort_direction' => 'asc']],
+ 'unknown' => ['featured', null],
+]);
+
+test('review listing and rating counts are empty without storefront context', function () {
+ session([
+ 'storefront_store' => null,
+ 'storefront_network' => null,
+ ]);
+ $controller = new ReviewController();
+
+ $reviews = $controller->query(Request::create('/reviews'));
+ $counts = $controller->count(Request::create('/reviews/count'));
+
+ expect($reviews->resource)->toBeEmpty()
+ ->and($counts->getStatusCode())->toBe(200)
+ ->and($counts->getData(true))->toBe([]);
+});
+
+test('review rating counts are scoped to the active storefront store', function () {
+ createReviewControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('reviews')->insert([
+ ['subject_uuid' => 'store_uuid', 'rating' => 1],
+ ['subject_uuid' => 'store_uuid', 'rating' => 5],
+ ['subject_uuid' => 'store_uuid', 'rating' => 5],
+ ['subject_uuid' => 'other_store', 'rating' => 5],
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+
+ $response = (new ReviewController())->count(Request::create('/reviews/count'));
+
+ expect($response->getData(true))->toBe([
+ 1 => 1,
+ 2 => 0,
+ 3 => 0,
+ 4 => 0,
+ 5 => 2,
+ ]);
+});
+
+test('review listing applies storefront ownership sorting limits and offsets', function () {
+ createReviewControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('reviews')->insert([
+ [
+ 'uuid' => 'review_one_uuid',
+ 'public_id' => 'review_one',
+ 'subject_uuid' => 'store_uuid',
+ 'rating' => 1,
+ 'content' => 'First',
+ 'created_at' => '2026-01-01 00:00:00',
+ 'updated_at' => '2026-01-01 00:00:00',
+ ],
+ [
+ 'uuid' => 'review_two_uuid',
+ 'public_id' => 'review_two',
+ 'subject_uuid' => 'store_uuid',
+ 'rating' => 5,
+ 'content' => 'Second',
+ 'created_at' => '2026-01-02 00:00:00',
+ 'updated_at' => '2026-01-02 00:00:00',
+ ],
+ [
+ 'uuid' => 'review_other_uuid',
+ 'public_id' => 'review_other',
+ 'subject_uuid' => 'other_store',
+ 'rating' => 4,
+ 'content' => 'Other store',
+ 'created_at' => '2026-01-03 00:00:00',
+ 'updated_at' => '2026-01-03 00:00:00',
+ ],
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $request = Request::create('/reviews?limit=1&offset=1&sort=highest', 'GET', [
+ 'limit' => 1,
+ 'offset' => 1,
+ 'sort' => 'highest',
+ ]);
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'review-listing-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+
+ $resource = (new ReviewController())->query($request);
+
+ expect($request->input('sort'))->toBe('rating')
+ ->and($request->input('sort_direction'))->toBe('desc')
+ ->and($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('review_two_uuid');
+});
+
+test('network review listing and counts validate membership and apply pagination', function () {
+ createReviewControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['network_stores', 'networks', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ ]);
+ $connection->table('network_stores')->insert([
+ 'uuid' => 'network_store_uuid',
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ ]);
+ $connection->table('reviews')->insert([
+ [
+ 'uuid' => 'network_review_one',
+ 'public_id' => 'review_network_one',
+ 'subject_uuid' => 'store_uuid',
+ 'rating' => 1,
+ 'created_at' => '2026-01-01 00:00:00',
+ 'updated_at' => '2026-01-01 00:00:00',
+ ],
+ [
+ 'uuid' => 'network_review_two',
+ 'public_id' => 'review_network_two',
+ 'subject_uuid' => 'store_uuid',
+ 'rating' => 5,
+ 'created_at' => '2026-01-02 00:00:00',
+ 'updated_at' => '2026-01-02 00:00:00',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $controller = new ReviewController();
+ $missing = $controller->query(Request::create('/reviews?store=store_missing', 'GET', [
+ 'store' => 'store_missing',
+ ]));
+ $missingCount = $controller->count(Request::create('/reviews/count', 'GET', [
+ 'store' => 'store_missing',
+ ]));
+ $request = Request::create('/reviews?store=store_abcdefgh&limit=1&offset=1', 'GET', [
+ 'store' => 'store_abcdefgh',
+ 'limit' => 1,
+ 'offset' => 1,
+ ]);
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'network-review-listing-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+ $reviews = $controller->query($request);
+ $counts = $controller->count(Request::create('/reviews/count?store=store_abcdefgh', 'GET', [
+ 'store' => 'store_abcdefgh',
+ ]));
+
+ expect($missing->getStatusCode())->toBe(400)
+ ->and($missing->getData(true))->toBe(['error' => 'Cannot find reviews for store'])
+ ->and($missingCount->getStatusCode())->toBe(400)
+ ->and($missingCount->getData(true))->toBe(['error' => 'Cannot count reviews for store'])
+ ->and($reviews->resource)->toHaveCount(1)
+ ->and($reviews->resource->first()->uuid)->toBe('network_review_two')
+ ->and($counts->getData(true))->toBe([
+ 1 => 1,
+ 2 => 0,
+ 3 => 0,
+ 4 => 0,
+ 5 => 1,
+ ]);
+});
+
+test('review find and delete return not-found contracts for unknown public ids', function () {
+ createReviewControllerSchema();
+ session(['company' => null]);
+ $controller = new ReviewController();
+
+ $find = $controller->find('missing_review');
+ $delete = $controller->delete('missing_review');
+
+ expect($find->getStatusCode())->toBe(400)
+ ->and($find->getData(true))->toBe(['error' => 'Review resource not found.'])
+ ->and($delete->getStatusCode())->toBe(400)
+ ->and($delete->getData(true))->toBe(['error' => 'Review resource not found.']);
+});
+
+test('review find and delete return and soft delete persisted review resources', function () {
+ createReviewControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('reviews')->insert([
+ 'uuid' => 'review_uuid',
+ 'public_id' => 'review_abcdefgh',
+ 'subject_uuid' => 'store_uuid',
+ 'rating' => 5,
+ 'content' => 'Excellent',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $controller = new ReviewController();
+
+ $found = $controller->find('review_abcdefgh');
+ $deleted = $controller->delete('review_abcdefgh');
+
+ expect($found->resource->uuid)->toBe('review_uuid')
+ ->and($deleted->resource->uuid)->toBe('review_uuid')
+ ->and($connection->table('reviews')->where('uuid', 'review_uuid')->value('deleted_at'))->not->toBeNull();
+});
+
+test('review creation enforces customer authentication and subject validity', function () {
+ createReviewControllerSchema();
+ session(['storefront_key' => null]);
+ $unauthenticatedRequest = Request::create('/reviews', 'POST', [
+ 'subject' => 'store_abcdefgh',
+ 'rating' => 5,
+ 'content' => 'Excellent',
+ ]);
+ app()->instance('request', $unauthenticatedRequest);
+ $controller = new ReviewController();
+
+ $unauthorized = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'store_abcdefgh',
+ 'rating' => 5,
+ ])
+ );
+
+ expect($unauthorized->getData(true))->toBe(['error' => 'Not authorized to create reviews']);
+});
+
+test('authenticated review creation persists customer and store subject contracts', function () {
+ createReviewControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'files', 'contacts', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type')->nullable();
+ $table->integer('tokenable_id')->nullable();
+ $table->string('name');
+ $table->string('token');
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('website')->nullable();
+ $table->string('facebook')->nullable();
+ $table->string('instagram')->nullable();
+ $table->string('twitter')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('timezone')->nullable();
+ $table->string('pod_method')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('uploader_uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('original_filename')->nullable();
+ $table->string('extension')->nullable();
+ $table->string('content_type')->nullable();
+ $table->string('path')->nullable();
+ $table->string('bucket')->nullable();
+ $table->string('type')->nullable();
+ $table->integer('file_size')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $customerUuid = '11111111-1111-4111-8111-111111111111';
+ $connection->table('contacts')->insert([
+ 'uuid' => $customerUuid,
+ 'public_id' => 'contact_abcdefgh',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'name' => $customerUuid,
+ 'token' => hash('sha256', 'review-customer-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Review store',
+ ]);
+ $boundRequest = Request::create('/reviews');
+ $boundRequest->headers->set('Customer-Token', 'review-customer-secret');
+ $boundRequest->setLaravelSession(new Illuminate\Session\Store(
+ 'review-customer-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $boundRequest);
+ session(['company' => 'company_uuid', 'storefront_key' => null]);
+ $controller = new ReviewController();
+
+ $invalid = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'store_missing',
+ 'rating' => 2,
+ ])
+ );
+ $created = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'store_abcdefgh',
+ 'rating' => 5,
+ 'content' => 'Excellent service',
+ ])
+ );
+ $review = $connection->table('reviews')->first();
+ Illuminate\Support\Facades\Storage::swap(new class {
+ public function disk(string $disk): self
+ {
+ return $this;
+ }
+
+ public function put(string $path, string $contents, string $visibility): bool
+ {
+ return true;
+ }
+ });
+ session(['storefront_key' => 'store_key']);
+ $withPhoto = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'store_abcdefgh',
+ 'rating' => 4,
+ 'content' => 'Photo review',
+ 'disk' => 'local',
+ 'bucket' => 'review-bucket',
+ 'files' => [
+ [
+ 'data' => base64_encode('image-bytes'),
+ 'type' => 'image/png',
+ ],
+ ],
+ ])
+ );
+ $photo = $connection->table('files')->first();
+
+ expect($invalid->getData(true))->toBe(['error' => 'Invalid subject for review'])
+ ->and($created->resource->uuid)->toBe($review->uuid)
+ ->and($review->created_by_uuid)->toBe('user_uuid')
+ ->and($review->customer_uuid)->toBe($customerUuid)
+ ->and($review->subject_uuid)->toBe('store_uuid')
+ ->and($review->rating)->toBe(5)
+ ->and($review->content)->toBe('Excellent service')
+ ->and($withPhoto->resource->files)->toHaveCount(1)
+ ->and($photo->subject_uuid)->toBe($withPhoto->resource->uuid)
+ ->and($photo->content_type)->toBe('image/png')
+ ->and($photo->bucket)->toBe('review-bucket')
+ ->and($photo->file_size)->toBe(strlen('image-bytes'))
+ ->and($photo->type)->toBe('storefront_review_upload');
+});
+
+test('customer order actions require a customer token before order lookup', function () {
+ $boundRequest = Request::create('/orders');
+ $boundRequest->setLaravelSession(new Illuminate\Session\Store(
+ 'customer-order-action-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $boundRequest);
+ $controller = new OrderController();
+
+ $pickup = $controller->completeOrderPickup(Request::create('/orders/pickup', 'POST'));
+ $receipt = $controller->getReceipt(Request::create('/orders/receipt'));
+
+ expect($pickup->getStatusCode())->toBe(400)
+ ->and($pickup->getData(true))->toBe(['error' => 'Customer is not authenticated.'])
+ ->and($receipt->getStatusCode())->toBe(400)
+ ->and($receipt->getData(true))->toBe(['error' => 'Customer is not authenticated.']);
+});
+
+test('internal order actions return explicit errors for unknown orders', function () {
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $controller = new InternalOrderController();
+
+ $find = $controller->findRecord(Request::create('/orders/missing'), 'missing_order');
+ $accept = $controller->acceptOrder(Request::create('/orders/accept', 'POST', ['order' => 'missing_uuid']));
+ $ready = $controller->markOrderAsReady(Request::create('/orders/ready', 'POST', ['order' => 'missing_uuid']));
+ $preparing = $controller->markOrderAsPreparing(Request::create('/orders/preparing', 'POST', ['order' => 'missing_uuid']));
+ $completed = $controller->markOrderAsCompleted(Request::create('/orders/completed', 'POST', ['order' => 'missing_uuid']));
+ $unassign = $controller->unassignDriver(Request::create('/orders/unassign', 'POST', ['order' => 'missing_uuid']));
+ $reject = $controller->rejectOrder(Request::create('/orders/reject', 'POST', ['order' => 'missing_uuid']));
+
+ expect($find->getStatusCode())->toBe(404)
+ ->and($find->getData(true))->toBe(['error' => 'Order not found'])
+ ->and($accept->getData(true))->toBe(['error' => 'No order to accept!'])
+ ->and($ready->getData(true))->toBe(['error' => 'No order to update!'])
+ ->and($preparing->getData(true))->toBe(['error' => 'No order to update!'])
+ ->and($completed->getData(true))->toBe(['error' => 'No order to update!'])
+ ->and($unassign->getData(true))->toBe(['error' => 'No order to update!'])
+ ->and($reject->getData(true))->toBe(['error' => 'No order to cancel!']);
+});
+
+test('internal order controller delegates query config activity notification and response contracts', function () {
+ $controller = new InternalOrderControllerProbe();
+ $query = (new Fleetbase\FleetOps\Models\Order())->newQuery();
+ $controller->onQueryRecord($query);
+ expect(array_keys($query->getEagerLoads()))->toBe([
+ 'customer',
+ 'transaction',
+ 'payload',
+ 'driverAssigned',
+ 'orderConfig',
+ 'trackingNumber',
+ 'trackingStatuses',
+ ]);
+
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $schema->dropIfExists('order_configs');
+ $schema->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('name')->nullable();
+ $table->string('namespace')->nullable();
+ $table->string('key')->nullable();
+ $table->string('status')->nullable();
+ $table->string('version')->nullable();
+ $table->text('activities')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ $table->timestamps();
+ });
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid',
+ 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter',
+ 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamp('deleted_at')->nullable();
+ });
+ Model::getConnectionResolver()->connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Runtime Store',
+ ]);
+ $config = Fleetbase\FleetOps\Models\OrderConfig::forceCreate([
+ 'uuid' => 'order_config_uuid',
+ 'name' => 'Storefront delivery',
+ 'namespace' => 'fleetbase:order-config:storefront-delivery',
+ 'activities' => '[]',
+ ]);
+ $order = new OrderActionStub();
+ $order->forceFill([
+ 'order_config_uuid' => $config->uuid,
+ 'status' => 'preparing',
+ 'public_id' => 'order_public',
+ 'meta' => ['storefront_id' => 'store_public'],
+ ]);
+ $customer = new NotifiableOrderCustomerStub();
+ $order->setRelation('customer', $customer);
+
+ $patched = $controller->patch($order);
+ $activity = $controller->acceptedActivity();
+ $controller->notifyAccepted($order);
+ $response = $controller->responseFor($order);
+
+ expect($patched->uuid)->toBe($config->uuid)
+ ->and($activity->code)->toBe('accepted')
+ ->and($customer->notified)->toBeTrue()
+ ->and($response['status'])->toBe('preparing')
+ ->and($response['order'])->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Order::class);
+});
+
+test('authenticated customer order endpoints distinguish missing and unauthorized orders', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'contacts', 'entities', 'places', 'waypoints', 'payloads', 'checkouts', 'gateways', 'orders'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type')->nullable();
+ $table->integer('tokenable_id')->nullable();
+ $table->string('name');
+ $table->string('token');
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('customer_type')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('payloads', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('payment_method')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('entities', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('waypoints', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('checkouts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('gateways', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('code')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->text('config')->nullable();
+ $table->string('callback_url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('contacts')->insert([
+ 'uuid' => '11111111-1111-4111-8111-111111111111',
+ 'public_id' => 'contact_customer',
+ 'type' => 'customer',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'name' => '11111111-1111-4111-8111-111111111111',
+ 'token' => hash('sha256', 'customer-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $boundRequest = Request::create('/orders');
+ $boundRequest->headers->set('Customer-Token', 'customer-secret');
+ $boundRequest->setLaravelSession(new Illuminate\Session\Store(
+ 'authenticated-customer-order-test',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $boundRequest);
+ $controller = new OrderController();
+
+ $missingPickup = $controller->completeOrderPickup(Request::create('/orders/pickup', 'POST', [
+ 'order' => 'order_missing',
+ ]));
+ $missingReceipt = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_missing',
+ ]));
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_other_customer',
+ 'customer_uuid' => '22222222-2222-4222-8222-222222222222',
+ ]);
+ $unauthorizedPickup = $controller->completeOrderPickup(Request::create('/orders/pickup', 'POST', [
+ 'order' => 'order_other_customer',
+ ]));
+ $unauthorizedReceipt = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_other_customer',
+ ]));
+ $connection->table('payloads')->insert([
+ ['uuid' => 'payload_cash', 'payment_method' => 'cash'],
+ ['uuid' => 'payload_qpay', 'payment_method' => 'qpay'],
+ ]);
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => 'order_cash_uuid',
+ 'public_id' => 'order_cash',
+ 'customer_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'payload_uuid' => 'payload_cash',
+ 'meta' => null,
+ ],
+ [
+ 'uuid' => 'order_qpay_uuid',
+ 'public_id' => 'order_qpay',
+ 'customer_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'payload_uuid' => 'payload_qpay',
+ 'meta' => json_encode(['checkout_id' => 'checkout_qpay']),
+ ],
+ ]);
+ $cashReceipt = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_cash',
+ ]));
+ $qpayCompanyReceipt = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay',
+ 'ebarimt_receiver_type' => 'company',
+ ]));
+ $qpayMissingCheckout = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay',
+ ]));
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_qpay_without_checkout_uuid',
+ 'public_id' => 'order_qpay_without_checkout',
+ 'customer_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'payload_uuid' => 'payload_qpay',
+ 'meta' => null,
+ ]);
+ $qpayWithoutCheckoutMeta = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay_without_checkout',
+ ]));
+ $connection->table('checkouts')->insert([
+ 'uuid' => 'checkout_qpay_uuid',
+ 'public_id' => 'checkout_qpay',
+ 'options' => json_encode(['qpay_invoice_id' => 'invoice_qpay']),
+ ]);
+ $qpayMissingGateway = $controller->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay',
+ ]));
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'qpay',
+ 'owner_uuid' => session('storefront_store') ?? session('storefront_network'),
+ 'sandbox' => true,
+ 'callback_url' => 'https://example.test/qpay',
+ 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']),
+ ]);
+ $qpay = new ReceiptQPayStub('username', 'password', 'https://example.test/callback');
+ $qpay->payment = (object) ['payment_id' => 'payment_qpay'];
+ $qpay->response = (object) ['ebarimt_qr_data' => 'receipt-qr', 'lottery' => 'lottery-code'];
+ $successfulController = new CustomerOrderControllerStub();
+ $successfulController->qpay = $qpay;
+ $qpayReceipt = $successfulController->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay',
+ ]));
+ $successfulController->failReceipt = true;
+ $failedQpayReceipt = $successfulController->getReceipt(Request::create('/orders/receipt', 'POST', [
+ 'order' => 'order_qpay',
+ ]));
+ $pickupController = new CustomerPickupControllerStub();
+ $pickupResponse = $pickupController->completeOrderPickup(Request::create('/orders/pickup', 'POST', [
+ 'order' => 'order_cash',
+ ]));
+ $probeOrder = new OrderActionStub();
+ $probeOrder->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ $probe = new CustomerOrderControllerProbe();
+ $probe->patch($probeOrder);
+ $probe->updateStatus($probeOrder, 'completed');
+ $qpayFactoryResult = $probe->qpay('merchant', 'secret', 'https://example.test/qpay');
+
+ expect($missingPickup->getData(true))->toBe(['error' => 'No order found.'])
+ ->and($missingReceipt->getData(true))->toBe(['error' => 'No order found.'])
+ ->and($unauthorizedPickup->getData(true))->toBe([
+ 'error' => 'Not authorized to pickup this order for completion.',
+ ])->and($unauthorizedReceipt->getData(true))->toBe([
+ 'error' => 'Not authorized to get receipt for this order.',
+ ])->and($cashReceipt->getData(true))->toBe([
+ 'message' => 'No receipt available for this payment method.',
+ 'payment_method' => 'cash',
+ ])->and($qpayCompanyReceipt->getData(true))->toBe([
+ 'error' => 'Company registration number is required.',
+ ])->and($qpayMissingCheckout->getData(true))->toBe([
+ 'error' => 'No checkout found for this order.',
+ ])->and($qpayWithoutCheckoutMeta->getData(true))->toBe([
+ 'error' => 'No checkout found for this order.',
+ ])->and($qpayMissingGateway->getData(true))->toBe([
+ 'error' => 'QPay is not configured.',
+ ])->and($qpayReceipt->getData(true))->toBe([
+ 'ebarimt_qr_data' => 'receipt-qr',
+ 'lottery' => 'lottery-code',
+ ])->and($qpay->posted['path'])->toBe('ebarimt_v3/create')
+ ->and($qpay->sandboxed)->toBeTrue()
+ ->and($qpay->authenticated)->toBeTrue()
+ ->and($failedQpayReceipt->getData(true))->toBe(['error' => 'Receipt provider failed.'])
+ ->and($pickupResponse->getData(true))->toBe(['status' => 'completed', 'order' => 'order_cash'])
+ ->and($probeOrder->calls)->toContain('update_status:completed')
+ ->and($qpayFactoryResult)->toBeInstanceOf(QPay::class);
+});
+
+test('QPay receipt creation sends citizen and company contracts and surfaces provider errors', function () {
+ $controller = new OrderController();
+ $method = new ReflectionMethod($controller, 'createEbarimtReceipt');
+ $qpay = new ReceiptQPayStub('username', 'password', 'https://example.test/callback');
+
+ $qpay->response = (object) ['ebarimt_qr_data' => 'qr-data'];
+ $citizen = $method->invoke(
+ $controller,
+ $qpay,
+ (object) ['payment_id' => 'payment_citizen'],
+ 'CITIZEN',
+ null
+ );
+ $citizenRequest = $qpay->posted;
+
+ $qpay->response = (object) ['ebarimt_qr_data' => 'company-qr'];
+ $company = $method->invoke(
+ $controller,
+ $qpay,
+ ['payment_id' => 'payment_company'],
+ 'COMPANY',
+ '1234567'
+ );
+ $companyRequest = $qpay->posted;
+
+ $qpay->response = (object) ['error' => 'Provider rejected receipt'];
+ $error = $method->invoke(
+ $controller,
+ $qpay,
+ (object) ['payment_id' => 'payment_error'],
+ 'CITIZEN',
+ null
+ );
+
+ expect($citizen->ebarimt_qr_data)->toBe('qr-data')
+ ->and($citizenRequest['path'])->toBe('ebarimt_v3/create')
+ ->and($citizenRequest['params'])->toBe([
+ 'payment_id' => 'payment_citizen',
+ 'ebarimt_receiver_type' => 'CITIZEN',
+ ])->and($company->ebarimt_qr_data)->toBe('company-qr')
+ ->and($companyRequest['params'])->toBe([
+ 'payment_id' => 'payment_company',
+ 'ebarimt_receiver_type' => 'COMPANY',
+ 'ebarimt_receiver' => '1234567',
+ ])->and($error->getData(true))->toBe([
+ 'error' => 'Provider rejected receipt',
+ ]);
+});
+
+test('QPay receipt helper rejects an order whose loaded payload is not QPay', function () {
+ $controller = new OrderController();
+ $method = new ReflectionMethod($controller, 'getQpayEbarimtReceipt');
+ $payload = new Fleetbase\FleetOps\Models\Payload();
+ $payload->forceFill(['payment_method' => 'cash']);
+ $order = new Fleetbase\FleetOps\Models\Order();
+ $order->setRelation('payload', $payload);
+
+ $response = $method->invoke(
+ $controller,
+ Request::create('/orders/receipt', 'POST'),
+ $order
+ );
+
+ expect($response->getData(true))->toBe([
+ 'error' => 'This order was not paid using QPay.',
+ ]);
+});
+
+test('internal order acceptance handles pickup activity notification and status failures', function () {
+ $controller = new InternalOrderActionControllerStub();
+ $order = new OrderActionStub();
+ $order->pickup = true;
+ $order->public_id = 'order_public';
+ $controller->order = $order;
+
+ $found = $controller->findRecord(Request::create('/orders/order_public'), 'order_public');
+ $accepted = $controller->acceptOrder(Request::create('/orders/accept', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+
+ expect($found['order']->resource)->toBe($order)
+ ->and($accepted)->toBe(['status' => 'preparing', 'order' => 'order_public'])
+ ->and($order->calls)->toContain('first_dispatch', 'set:preparing', 'activity:preparing', 'notified')
+ ->and($controller->patches)->toBe(1);
+
+ $controller->notificationFails = true;
+ $order->calls = [];
+ $acceptedWithoutNotification = $controller->acceptOrder(Request::create('/orders/accept', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($acceptedWithoutNotification['status'])->toBe('preparing')
+ ->and($order->calls)->not->toContain('notified');
+
+ $order->failStatus = true;
+ $failed = $controller->acceptOrder(Request::create('/orders/accept', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($failed->getData(true))->toBe(['error' => 'Unable to accept order.']);
+});
+
+test('internal ready action handles pickup and dispatched delivery transitions', function () {
+ $controller = new InternalOrderActionControllerStub();
+ $pickup = new OrderActionStub();
+ $pickup->pickup = true;
+ $pickup->public_id = 'pickup_order';
+ $controller->order = $pickup;
+
+ $pickupResponse = $controller->markOrderAsReady(Request::create('/orders/ready', 'POST', [
+ 'order' => 'pickup_uuid',
+ ]));
+
+ $delivery = new OrderActionStub();
+ $delivery->forceFill([
+ 'public_id' => 'delivery_order',
+ 'adhoc' => false,
+ ]);
+ $controller->order = $delivery;
+ $deliveryResponse = $controller->markOrderAsReady(Request::create('/orders/ready', 'POST', [
+ 'order' => 'delivery_uuid',
+ 'adhoc' => true,
+ 'driver' => 'driver_uuid',
+ ]));
+
+ expect($pickupResponse)->toBe(['status' => 'pickup_ready', 'order' => 'pickup_order'])
+ ->and($pickup->calls)->toContain('update_status:pickup_ready')
+ ->and($deliveryResponse)->toBe(['status' => 'preparing', 'order' => 'delivery_order'])
+ ->and($delivery->adhoc)->toBeTrue()
+ ->and($delivery->calls)->toContain('update', 'driver:driver_uuid', 'dispatch', 'update_status:preparing');
+});
+
+test('internal preparing completed rejected and driver-unassignment actions preserve transitions', function () {
+ $controller = new InternalOrderActionControllerStub();
+ $order = new OrderActionStub();
+ $order->public_id = 'order_public';
+ $controller->order = $order;
+
+ $preparing = $controller->markOrderAsPreparing(Request::create('/orders/preparing', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($preparing)->toBe(['status' => 'preparing', 'order' => 'order_public'])
+ ->and($order->calls)->toContain('set:preparing', 'activity:preparing');
+
+ $order->failStatus = true;
+ $failedPreparing = $controller->markOrderAsPreparing(Request::create('/orders/preparing', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($failedPreparing->getData(true))->toBe(['error' => 'Unable to trigger order preparing.']);
+
+ $order->failStatus = false;
+ $order->pickup = true;
+ $completedPickup = $controller->markOrderAsCompleted(Request::create('/orders/completed', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($completedPickup['status'])->toBe('picked_up');
+
+ $order->pickup = false;
+ $completedDelivery = $controller->markOrderAsCompleted(Request::create('/orders/completed', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($completedDelivery['status'])->toBe('completed');
+
+ $rejected = $controller->rejectOrder(Request::create('/orders/reject', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+ expect($rejected['status'])->toBe('canceled');
+
+ $driver = new DriverAssignmentStub();
+ $order->setRelation('driverAssigned', $driver);
+ $order->forceFill([
+ 'driver_assigned_uuid' => 'driver_uuid',
+ 'vehicle_assigned_uuid' => 'vehicle_uuid',
+ ]);
+ $unassigned = $controller->unassignDriver(Request::create('/orders/unassign', 'POST', [
+ 'order' => 'order_uuid',
+ ]));
+
+ expect($driver->unassigned)->toBeTrue()
+ ->and($order->driver_assigned_uuid)->toBeNull()
+ ->and($order->vehicle_assigned_uuid)->toBeNull()
+ ->and($unassigned['status'])->toBe('canceled');
+
+ $order->setRelation('driverAssigned', null);
+ expect($controller->unassignDriver(Request::create('/orders/unassign', 'POST', [
+ 'order' => 'order_uuid',
+ ]))['status'])->toBe('canceled');
+});
diff --git a/server/tests/Unit/Http/Controllers/SearchControllerTest.php b/server/tests/Unit/Http/Controllers/SearchControllerTest.php
new file mode 100644
index 00000000..eb3605bb
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/SearchControllerTest.php
@@ -0,0 +1,202 @@
+invoke(new SearchController(), ...$arguments);
+}
+
+test('search endpoint returns an empty contract before authorization or database work', function ($input) {
+ $request = Request::create('/search', 'GET', $input);
+ $response = (new SearchController())->search($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toBe(['results' => []]);
+})->with([
+ 'no query' => [[]],
+ 'blank query' => [['query' => ' ']],
+ 'blank short query' => [['q' => "\t"]],
+]);
+
+test('search type parsing supports comma lists arrays and safe defaults', function ($input, $expected) {
+ $request = Request::create('/search', 'GET', ['types' => $input]);
+
+ expect(invokeSearchController('requestedTypes', $request))->toBe($expected);
+})->with([
+ 'comma list' => [
+ 'products, orders,invalid',
+ ['products', 'orders'],
+ ],
+ 'array' => [
+ ['stores', 'gateways'],
+ ['stores', 'gateways'],
+ ],
+ 'invalid scalar' => [
+ 42,
+ ['products', 'catalogs', 'customers', 'orders', 'networks', 'stores', 'food-trucks', 'gateways', 'notification-channels'],
+ ],
+ 'empty filtered list' => [
+ ['invalid'],
+ ['products', 'catalogs', 'customers', 'orders', 'networks', 'stores', 'food-trucks', 'gateways', 'notification-channels'],
+ ],
+]);
+
+test('search type dispatcher has a safe empty default', function () {
+ expect(invokeSearchController('searchType', 'unsupported', 'query', 5, null))
+ ->toBeInstanceOf(Illuminate\Support\Collection::class)
+ ->toBeEmpty();
+});
+
+test('search store resolver ignores requests without storefront scope', function () {
+ $request = Request::create('/search', 'GET', ['query' => 'coffee']);
+
+ expect(invokeSearchController('storefront', $request))->toBeNull();
+});
+
+test('search skips resource types the current user cannot access', function () {
+ Auth::$user = null;
+ Auth::$permissions = [];
+
+ $response = (new SearchController())->search(Request::create('/search', 'GET', [
+ 'query' => 'restricted',
+ 'types' => ['products'],
+ ]));
+
+ expect($response->getData(true))->toBe(['results' => []]);
+});
+
+test('search wildcard builder escapes user-controlled percent and underscore characters', function () {
+ $model = new class extends Model {
+ protected $table = 'products';
+ };
+ $builder = $model->newQuery();
+
+ invokeSearchController('whereLike', $builder, ['name', 'sku'], '100%_pure');
+
+ expect($builder->toSql())->toContain('"name" like ?')
+ ->and($builder->toSql())->toContain('or "sku" like ?')
+ ->and($builder->getBindings())->toBe([
+ '%100\\%\\_pure%',
+ '%100\\%\\_pure%',
+ ]);
+});
+
+test('search returns authorized results across every supported storefront resource type', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $definitions = [
+ 'products' => ['public_id', 'uuid', 'name', 'description', 'sku', 'status', 'company_uuid', 'store_uuid'],
+ 'catalogs' => ['public_id', 'uuid', 'name', 'description', 'status', 'company_uuid', 'store_uuid'],
+ 'contacts' => ['public_id', 'uuid', 'name', 'email', 'phone', 'internal_id', 'company_uuid', 'type'],
+ 'orders' => ['public_id', 'uuid', 'internal_id', 'status', 'company_uuid', 'customer_uuid', 'customer_type', 'meta'],
+ 'networks' => ['public_id', 'uuid', 'name', 'description', 'email', 'phone', 'website', 'company_uuid'],
+ 'stores' => ['public_id', 'uuid', 'name', 'description', 'email', 'phone', 'website', 'company_uuid'],
+ 'food_trucks' => ['public_id', 'uuid', 'status', 'company_uuid', 'store_uuid'],
+ 'gateways' => ['public_id', 'uuid', 'name', 'description', 'code', 'type', 'company_uuid', 'owner_uuid'],
+ 'notification_channels' => ['uuid', 'name', 'scheme', 'app_key', 'company_uuid', 'owner_uuid'],
+ ];
+
+ foreach ($definitions as $tableName => $columns) {
+ $schema->dropIfExists($tableName);
+ $schema->create($tableName, function ($table) use ($columns) {
+ $table->increments('id');
+ foreach ($columns as $column) {
+ $table->string($column)->nullable();
+ }
+ $table->timestamp('deleted_at')->nullable();
+ });
+ }
+ $schema->dropIfExists('network_stores');
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ $common = ['company_uuid' => 'company_uuid'];
+ $connection->table('products')->insert($common + ['uuid' => 'product_uuid', 'public_id' => 'product_match', 'name' => 'Match product']);
+ $connection->table('catalogs')->insert($common + ['uuid' => 'catalog_uuid', 'public_id' => 'catalog_match', 'name' => 'Match catalog']);
+ $connection->table('contacts')->insert($common + ['uuid' => 'customer_uuid', 'public_id' => 'contact_match', 'name' => 'Match customer', 'type' => 'customer']);
+ $connection->table('orders')->insert($common + [
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_match',
+ 'status' => 'created',
+ 'customer_uuid' => 'customer_uuid',
+ 'customer_type' => Fleetbase\Storefront\Models\Customer::class,
+ 'meta' => json_encode(['storefront_id' => 'store_match']),
+ ]);
+ $connection->table('networks')->insert($common + ['uuid' => 'network_uuid', 'public_id' => 'network_match', 'name' => 'Match network']);
+ $connection->table('stores')->insert($common + ['uuid' => 'store_uuid', 'public_id' => 'store_match', 'name' => 'Match store']);
+ $connection->table('food_trucks')->insert($common + ['uuid' => 'truck_uuid', 'public_id' => 'food_truck_match', 'status' => 'active']);
+ $connection->table('gateways')->insert($common + ['uuid' => 'gateway_uuid', 'public_id' => 'gateway_match', 'name' => 'Match gateway']);
+ $connection->table('notification_channels')->insert($common + ['uuid' => 'channel_match', 'name' => 'Match channel', 'scheme' => 'fcm', 'app_key' => 'match-app']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ ]);
+
+ session(['company' => 'company_uuid']);
+ Auth::$permissions = [
+ 'storefront see product',
+ 'storefront see catalog',
+ 'storefront see customer',
+ 'storefront see order',
+ 'storefront see network',
+ 'storefront see store',
+ 'storefront see food-truck',
+ 'storefront see gateway',
+ 'storefront see notification-channel',
+ ];
+
+ $response = (new SearchController())->search(Request::create('/search', 'GET', [
+ 'query' => 'match',
+ 'limit' => 24,
+ ]));
+ $results = $response->getData(true)['results'];
+
+ expect($results)->toHaveCount(9)
+ ->and(array_column($results, 'type'))->toBe([
+ 'Product',
+ 'Catalog',
+ 'Customer',
+ 'Order',
+ 'Network',
+ 'Store',
+ 'Food Truck',
+ 'Gateway',
+ 'Notification Channel',
+ ]);
+
+ Auth::$user = new class {
+ public function isAdmin(): bool
+ {
+ return true;
+ }
+ };
+ Auth::$permissions = [];
+ $adminResponse = (new SearchController())->search(Request::create('/search', 'GET', [
+ 'query' => 'match',
+ 'types' => ['products'],
+ ]));
+ $scopedResponse = (new SearchController())->search(Request::create('/search', 'GET', [
+ 'query' => 'match',
+ 'types' => ['customers', 'networks'],
+ 'storefront' => 'store_match',
+ ]));
+
+ expect($adminResponse->getData(true)['results'])->toHaveCount(1)
+ ->and(array_column($scopedResponse->getData(true)['results'], 'type'))->toBe([
+ 'Customer',
+ 'Network',
+ ]);
+
+ Auth::$user = null;
+ Auth::$permissions = [];
+});
diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php
new file mode 100644
index 00000000..00abe373
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php
@@ -0,0 +1,1065 @@
+ 1200, 'time' => 300];
+ }
+
+ protected function getNetworkDistanceMatrix($origins, Fleetbase\FleetOps\Models\Place $destination): object
+ {
+ return (object) ['distance' => 2400, 'time' => 600];
+ }
+
+ protected function getServiceRates(Fleetbase\FleetOps\Models\Place $destination, string $orderConfigKey, ?string $currency)
+ {
+ return static::$serviceRates;
+ }
+}
+
+class IntegratedVendorQuoteApiStub
+{
+ public ?string $requestId = null;
+ public array $arguments = [];
+ public Fleetbase\FleetOps\Models\ServiceQuote $quote;
+
+ public function setRequestId(string $requestId): static
+ {
+ $this->requestId = $requestId;
+
+ return $this;
+ }
+
+ public function getQuoteFromPreliminaryPayload(...$arguments): Fleetbase\FleetOps\Models\ServiceQuote
+ {
+ $this->arguments = $arguments;
+
+ return $this->quote;
+ }
+}
+
+class IntegratedVendorQuoteModelStub extends Fleetbase\FleetOps\Models\IntegratedVendor
+{
+ public IntegratedVendorQuoteApiStub $apiStub;
+
+ public function api()
+ {
+ return $this->apiStub;
+ }
+}
+
+class ServiceRateQuoteStub extends Fleetbase\FleetOps\Models\ServiceRate
+{
+ public int $quotedAmount = 0;
+ public array $quotedEntities = [];
+ public array $quotedWaypoints = [];
+
+ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $totalDistance = 0, ?int $totalTime = 0, ?bool $isCashOnDelivery = false, ?int $endpointCount = null)
+ {
+ $this->quotedEntities = collect($entities)->all();
+ $this->quotedWaypoints = $waypoints;
+
+ return [
+ $this->quotedAmount,
+ collect([
+ [
+ 'amount' => $this->quotedAmount,
+ 'currency' => $this->currency,
+ 'details' => 'Delivery charge',
+ 'code' => 'delivery_fee',
+ ],
+ ]),
+ ];
+ }
+}
+
+test('service quote controller delegates integrated vendor quotes with the complete preliminary contract', function () {
+ $quote = new Fleetbase\FleetOps\Models\ServiceQuote();
+ $quote->forceFill(['uuid' => 'quote_uuid']);
+ $api = new IntegratedVendorQuoteApiStub();
+ $api->quote = $quote;
+ $vendor = new IntegratedVendorQuoteModelStub();
+ $vendor->apiStub = $api;
+ $method = new ReflectionMethod(ServiceQuoteController::class, 'getIntegratedVendorQuote');
+
+ $result = $method->invoke(
+ new ServiceQuoteController(),
+ $vendor,
+ 'request_abcdefgh',
+ [['id' => 'place_origin'], ['id' => 'place_destination']],
+ 'express',
+ '2026-07-28 09:00:00',
+ true
+ );
+
+ expect($result)->toBe($quote)
+ ->and($api->requestId)->toBe('request_abcdefgh')
+ ->and($api->arguments)->toBe([
+ [['id' => 'place_origin'], ['id' => 'place_destination']],
+ [],
+ 'express',
+ '2026-07-28 09:00:00',
+ true,
+ ]);
+});
+
+function createServiceQuoteLookupSchema(): void
+{
+ Fleetbase\FleetOps\Models\Entity::expand(
+ 'fromStorefrontProduct',
+ Fleetbase\Storefront\Expansions\EntityExpansion::fromStorefrontProduct()
+ );
+
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['places', 'store_locations', 'stores', 'vehicles', 'food_trucks', 'products', 'files', 'carts', 'service_quote_items', 'service_quotes', 'service_rates', 'integrated_vendors'] as $table) {
+ $schema->dropIfExists($table);
+ }
+
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->text('location')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('vehicles', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('food_trucks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('vehicle_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->nullable();
+ $table->integer('sale_price')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('carts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('checkout_uuid')->nullable();
+ $table->string('customer_id')->nullable();
+ $table->string('unique_identifier')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('discount_code')->nullable();
+ $table->text('items')->nullable();
+ $table->text('events')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('service_quotes', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('request_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('service_rate_uuid')->nullable();
+ $table->string('payload_uuid')->nullable();
+ $table->integer('amount')->nullable();
+ $table->string('currency')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expired_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('service_quote_items', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('service_quote_uuid')->nullable();
+ $table->integer('amount')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('details')->nullable();
+ $table->string('code')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('service_rates', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('service_type')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('integrated_vendors', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('provider')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+}
+
+test('service quote place lookup resolves tenant places and rejects missing typed resources', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ 'uuid' => 'place_uuid',
+ 'public_id' => 'place_public',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_public',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'place_uuid',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_uuid',
+ ]);
+ app()->instance('geocoder', new class {
+ public function reverse(float $latitude, float $longitude): self
+ {
+ return $this;
+ }
+
+ public function get(): Illuminate\Support\Collection
+ {
+ return collect();
+ }
+ });
+ Geocoder\Laravel\Facades\Geocoder::clearResolvedInstance('geocoder');
+ $connection->getPdo()->sqliteCreateFunction('ST_GeomFromText', fn (string $wkt) => $wkt, 3);
+ $controller = new ServiceQuoteController();
+ $coordinates = $controller->getPlaceFromId('47.918,106.917');
+
+ expect($controller->getPlaceFromId('place_public')?->uuid)->toBe('place_uuid')
+ ->and($controller->getPlaceFromId(['place_public'])?->uuid)->toBe('place_uuid')
+ ->and($controller->getPlaceFromId('store_location_public')?->uuid)->toBe('place_uuid')
+ ->and($controller->getPlaceFromId('store_location_missing'))->toBeNull()
+ ->and($controller->getPlaceFromId('vehicle_missing'))->toBeNull()
+ ->and($controller->getPlaceFromId('food_truck_missing'))->toBeNull()
+ ->and($coordinates)->toBeInstanceOf(Fleetbase\FleetOps\Models\Place::class)
+ ->and($coordinates->company_uuid)->toBe('company_uuid')
+ ->and($controller->getPlaceFromId('unknown_place'))->toBeNull();
+});
+
+test('service quote from cart validates delivery endpoints before rate resolution', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_public',
+ 'storefront_currency' => 'USD',
+ ]);
+ $controller = new ServiceQuoteController();
+
+ $missingOrigin = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'origin' => 'place_missing',
+ 'destination' => 'place_missing',
+ 'cart' => 'browser-cart',
+ ]));
+ $missingDestination = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_missing',
+ 'cart' => 'browser-cart',
+ ]));
+
+ expect($missingOrigin->getData(true))->toBe(['error' => 'No delivery origin!'])
+ ->and($missingDestination->getData(true))->toBe(['error' => 'No delivery destination!']);
+});
+
+test('service quote rejects missing integrated facilitators without runtime errors', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ ['uuid' => 'origin_uuid', 'public_id' => 'place_origin', 'company_uuid' => 'company_uuid'],
+ ['uuid' => 'destination_uuid', 'public_id' => 'place_destination', 'company_uuid' => 'company_uuid'],
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => null,
+ ]);
+
+ $controller = new ServiceQuoteController();
+ $missingFacilitator = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'browser-cart',
+ 'facilitator' => 'integrated_vendor_missing',
+ ]));
+
+ expect($missingFacilitator->getData(true))->toBe(['error' => 'Integrated vendor not found!']);
+});
+
+test('service quote persists integrated facilitator route metadata and contains provider failures', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ ['uuid' => 'origin_uuid', 'public_id' => 'place_origin', 'company_uuid' => 'company_uuid'],
+ ['uuid' => 'destination_uuid', 'public_id' => 'place_destination', 'company_uuid' => 'company_uuid'],
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'vendor_uuid',
+ 'public_id' => 'integrated_vendor_public',
+ 'company_uuid' => 'company_uuid',
+ 'provider' => 'provider_public',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'service_quote_public',
+ 'company_uuid' => 'company_uuid',
+ 'amount' => 1200,
+ 'currency' => 'USD',
+ 'meta' => '{}',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_public',
+ ]);
+
+ ServiceQuoteProviderControllerStub::$quote = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail();
+ ServiceQuoteProviderControllerStub::$failure = null;
+ $controller = new ServiceQuoteProviderControllerStub();
+ $requestData = [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'browser-cart',
+ 'facilitator' => 'integrated_vendor_public',
+ 'service_type' => 'delivery',
+ 'scheduled_at' => '2026-08-01 10:00:00',
+ 'is_route_optimized' => false,
+ ];
+
+ $resource = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $quoteMeta = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail()->meta;
+
+ ServiceQuoteProviderControllerStub::$failure = new RuntimeException('Provider unavailable');
+ $failure = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ ServiceQuoteProviderControllerStub::$failure = null;
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($quoteMeta['origin'])->toBe('place_origin')
+ ->and($quoteMeta['destination'])->toBe('place_destination')
+ ->and(ServiceQuoteProviderControllerStub::$places)->toHaveCount(2)
+ ->and($failure->getData(true))->toBe(['error' => 'Provider unavailable']);
+});
+
+test('network service quote requires a delivery destination', function () {
+ createServiceQuoteLookupSchema();
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'network_public',
+ 'storefront_currency' => 'USD',
+ ]);
+
+ $controller = new ServiceQuoteController();
+ $response = $controller->fromCartForNetwork(
+ GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_missing',
+ 'cart' => 'network-cart',
+ ])
+ );
+ $routedResponse = $controller->fromCart(
+ GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_missing',
+ 'cart' => 'network-cart',
+ ])
+ );
+
+ expect($response->getData(true))->toBe(['error' => 'No delivery destination!'])
+ ->and($routedResponse->getData(true))->toBe(['error' => 'No delivery destination!']);
+});
+
+test('network service quote rejects missing integrated facilitators safely', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'network-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteController {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill(['uuid' => 'destination_uuid', 'public_id' => 'place_destination']);
+
+ return $place;
+ }
+ };
+
+ $missingFacilitator = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_destination',
+ 'cart' => 'network-cart',
+ 'facilitator' => 'integrated_vendor_missing',
+ ]));
+
+ expect($missingFacilitator->getData(true))->toBe(['error' => 'Integrated vendor not found!']);
+});
+
+test('network service quote derives origins from explicit and default store locations', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Corner Store',
+ 'options' => '{}',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_public',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'origin_uuid',
+ ]);
+ $connection->table('carts')->insert([
+ [
+ 'uuid' => 'explicit_cart_uuid',
+ 'public_id' => 'explicit_cart_public',
+ 'unique_identifier' => 'explicit-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'store_id' => 'store_public',
+ 'store_location_id' => 'store_location_public',
+ ],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'default_cart_uuid',
+ 'public_id' => 'default_cart_public',
+ 'unique_identifier' => 'default-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'store_id' => 'store_public',
+ 'store_location_id' => null,
+ ],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'network_public',
+ ]);
+ $controller = new class extends ServiceQuoteController {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill(['uuid' => 'destination_uuid', 'public_id' => 'place_destination']);
+
+ return $place;
+ }
+ };
+
+ $explicit = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_destination',
+ 'cart' => 'explicit-cart',
+ 'facilitator' => 'integrated_vendor_missing',
+ ]));
+ $default = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_destination',
+ 'cart' => 'default-cart',
+ 'facilitator' => 'integrated_vendor_missing',
+ ]));
+
+ expect($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!'])
+ ->and($default->getData(true))->toBe(['error' => 'Integrated vendor not found!']);
+});
+
+test('network service quote persists integrated facilitator origin metadata and provider errors', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_public',
+ 'place_uuid' => 'origin_uuid',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'network-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ ['store_location_id' => 'store_location_public'],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'vendor_uuid',
+ 'public_id' => 'integrated_vendor_public',
+ 'company_uuid' => 'company_uuid',
+ 'provider' => 'provider_public',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'service_quote_public',
+ 'company_uuid' => 'company_uuid',
+ 'amount' => 1800,
+ 'currency' => 'USD',
+ 'meta' => '{}',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'network_public',
+ ]);
+
+ ServiceQuoteProviderControllerStub::$quote = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail();
+ ServiceQuoteProviderControllerStub::$failure = null;
+ $controller = new ServiceQuoteProviderControllerStub();
+ $requestData = [
+ 'destination' => 'place_destination',
+ 'cart' => 'network-cart',
+ 'facilitator' => 'provider_public',
+ ];
+ $controllerWithDestination = new class extends ServiceQuoteProviderControllerStub {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill(['uuid' => 'destination_uuid', 'public_id' => 'place_destination']);
+
+ return $place;
+ }
+ };
+
+ $resource = $controllerWithDestination->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $quoteMeta = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail()->meta;
+
+ ServiceQuoteProviderControllerStub::$failure = new RuntimeException('Network provider unavailable');
+ $failure = $controllerWithDestination->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ ServiceQuoteProviderControllerStub::$failure = null;
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($quoteMeta['origin'])->toBe(['place_origin'])
+ ->and($quoteMeta['destination'])->toBe('place_destination')
+ ->and(ServiceQuoteProviderControllerStub::$places)->toHaveCount(2)
+ ->and($failure->getData(true))->toBe(['error' => 'Network provider unavailable']);
+});
+
+test('service quote returns a stable error when no rates or integrated providers serve the route', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([]),
+ 'events' => json_encode([]),
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteController {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill([
+ 'uuid' => is_array($id) ? 'place_origin' : (string) $id,
+ 'public_id' => is_array($id) ? 'place_origin' : (string) $id,
+ 'location' => new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917),
+ ]);
+
+ return $place;
+ }
+ };
+
+ $response = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'browser-cart',
+ ]));
+
+ expect($response->getData(true))->toBe(['error' => 'No service rates available!']);
+});
+
+test('service quote persists local rate lines and selects all matching and fallback currencies', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COFFEE-1',
+ 'price' => 900,
+ 'sale_price' => 800,
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ ['product_id' => 'product_public'],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteProviderControllerStub {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill(['uuid' => (string) $id, 'public_id' => (string) $id]);
+
+ return $place;
+ }
+ };
+ $rate = function (string $uuid, string $currency, int $amount): ServiceRateQuoteStub {
+ $rate = new ServiceRateQuoteStub();
+ $rate->forceFill([
+ 'uuid' => $uuid,
+ 'company_uuid' => 'rate_company_uuid',
+ 'currency' => $currency,
+ ]);
+ $rate->quotedAmount = $amount;
+
+ return $rate;
+ };
+ ServiceQuoteProviderControllerStub::$serviceRates = [
+ $rate('rate_usd_high', 'USD', 1500),
+ $rate('rate_usd_low', 'USD', 1000),
+ $rate('rate_eur', 'EUR', 500),
+ ];
+ $requestData = [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'browser-cart',
+ ];
+
+ $all = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ ...$requestData,
+ 'all' => true,
+ ]));
+ $matching = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $connection->table('carts')->where('uuid', 'cart_uuid')->update(['currency' => 'JPY']);
+ $fallback = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $capturedRate = ServiceQuoteProviderControllerStub::$serviceRates[1];
+ ServiceQuoteProviderControllerStub::$serviceRates = [];
+
+ expect($all)->toBeInstanceOf(Fleetbase\Http\Resources\FleetbaseResourceCollection::class)
+ ->and($all->collection)->toHaveCount(3)
+ ->and($matching)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($matching->resource->amount)->toBe(1000)
+ ->and($fallback->resource->amount)->toBe(500)
+ ->and($capturedRate->quotedEntities[0]->name)->toBe('Coffee')
+ ->and($capturedRate->quotedWaypoints)->toHaveCount(2)
+ ->and($connection->table('service_quotes')->count())->toBe(9)
+ ->and($connection->table('service_quote_items')->count())->toBe(9);
+});
+
+test('service quote falls back to an integrated provider when local rates are unavailable', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'vendor_uuid',
+ 'public_id' => 'integrated_vendor_public',
+ 'company_uuid' => null,
+ 'provider' => 'provider_public',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'service_quote_public',
+ 'company_uuid' => 'company_uuid',
+ 'amount' => 1200,
+ 'currency' => 'USD',
+ 'meta' => '{}',
+ ]);
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteProviderControllerStub {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill([
+ 'uuid' => (string) $id,
+ 'public_id' => (string) $id,
+ 'location' => new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917),
+ ]);
+
+ return $place;
+ }
+ };
+ ServiceQuoteProviderControllerStub::$quote = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail();
+ ServiceQuoteProviderControllerStub::$failure = null;
+ $requestData = [
+ 'origin' => 'place_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'browser-cart',
+ ];
+
+ $resource = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $quoteMeta = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail()->meta;
+
+ ServiceQuoteProviderControllerStub::$failure = new RuntimeException('Fallback provider unavailable');
+ $failure = $controller->fromCart(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ ServiceQuoteProviderControllerStub::$failure = null;
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($quoteMeta['origin'])->toBe('place_origin')
+ ->and($quoteMeta['destination'])->toBe('place_destination')
+ ->and($failure->getData(true))->toBe(['error' => 'Fallback provider unavailable']);
+});
+
+test('network service quote resolves comma separated fallback origins before reporting no rates', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ ['uuid' => 'place_one_uuid', 'public_id' => 'place_one'],
+ ['uuid' => 'place_two_uuid', 'public_id' => 'place_two'],
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'location_one_uuid',
+ 'public_id' => 'store_location_one',
+ 'place_uuid' => 'place_one_uuid',
+ ],
+ [
+ 'uuid' => 'location_two_uuid',
+ 'public_id' => 'store_location_two',
+ 'place_uuid' => 'place_two_uuid',
+ ],
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'network-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([]),
+ 'events' => json_encode([]),
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ session(['company' => null, 'storefront_key' => null]);
+ $controller = new class extends ServiceQuoteController {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill([
+ 'uuid' => 'destination_uuid',
+ 'public_id' => 'place_destination',
+ 'location' => new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917),
+ ]);
+
+ return $place;
+ }
+ };
+
+ $response = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'origin' => 'store_location_one,store_location_two',
+ 'destination' => 'place_destination',
+ 'cart' => 'network-cart',
+ ]));
+
+ expect($response->getData(true))->toBe(['error' => 'No service rates available!']);
+});
+
+test('network service quote persists local rate lines and selects the lowest matching quote', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COFFEE-1',
+ 'price' => 900,
+ 'sale_price' => 800,
+ ]);
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_origin',
+ 'place_uuid' => 'origin_uuid',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'network-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ ['product_id' => 'product_public'],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteProviderControllerStub {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill(['uuid' => 'destination_uuid', 'public_id' => 'place_destination']);
+
+ return $place;
+ }
+ };
+ $high = new ServiceRateQuoteStub();
+ $high->forceFill(['uuid' => 'rate_high', 'company_uuid' => 'company_uuid', 'currency' => 'USD']);
+ $high->quotedAmount = 2200;
+ $low = new ServiceRateQuoteStub();
+ $low->forceFill(['uuid' => 'rate_low', 'company_uuid' => 'company_uuid', 'currency' => 'USD']);
+ $low->quotedAmount = 1700;
+ ServiceQuoteProviderControllerStub::$serviceRates = [$high, $low];
+ $requestData = [
+ 'origin' => 'store_location_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'network-cart',
+ ];
+
+ $matching = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $all = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ ...$requestData,
+ 'all' => true,
+ ]));
+ $connection->table('carts')->where('uuid', 'cart_uuid')->update(['currency' => 'JPY']);
+ $fallback = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ ServiceQuoteProviderControllerStub::$serviceRates = [];
+
+ expect($matching)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($matching->resource->amount)->toBe(1700)
+ ->and($all)->toBeInstanceOf(Fleetbase\Http\Resources\FleetbaseResourceCollection::class)
+ ->and($all->collection)->toHaveCount(2)
+ ->and($fallback->resource->amount)->toBe(1700)
+ ->and($low->quotedEntities[0]->name)->toBe('Coffee')
+ ->and($low->quotedWaypoints)->toHaveCount(2)
+ ->and($connection->table('service_quotes')->count())->toBe(6)
+ ->and($connection->table('service_quote_items')->count())->toBe(6);
+});
+
+test('network service quote falls back to an integrated provider when local rates are unavailable', function () {
+ createServiceQuoteLookupSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_origin',
+ 'place_uuid' => 'origin_uuid',
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'network-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('integrated_vendors')->insert([
+ 'uuid' => 'vendor_uuid',
+ 'public_id' => 'integrated_vendor_public',
+ 'company_uuid' => null,
+ 'provider' => 'provider_public',
+ ]);
+ $connection->table('service_quotes')->insert([
+ 'uuid' => 'quote_uuid',
+ 'public_id' => 'service_quote_public',
+ 'company_uuid' => 'company_uuid',
+ 'amount' => 1800,
+ 'currency' => 'USD',
+ 'meta' => '{}',
+ ]);
+ config(['fleetops.distance_matrix.provider' => 'calculate']);
+ session([
+ 'company' => null,
+ 'storefront_key' => null,
+ ]);
+ $controller = new class extends ServiceQuoteProviderControllerStub {
+ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
+ {
+ $place = new Fleetbase\FleetOps\Models\Place();
+ $place->forceFill([
+ 'uuid' => 'destination_uuid',
+ 'public_id' => 'place_destination',
+ 'location' => new Fleetbase\LaravelMysqlSpatial\Types\Point(47.918, 106.917),
+ ]);
+
+ return $place;
+ }
+ };
+ ServiceQuoteProviderControllerStub::$quote = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail();
+ ServiceQuoteProviderControllerStub::$failure = null;
+ $requestData = [
+ 'origin' => 'store_location_origin',
+ 'destination' => 'place_destination',
+ 'cart' => 'network-cart',
+ ];
+
+ $resource = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ $quoteMeta = Fleetbase\FleetOps\Models\ServiceQuote::where('uuid', 'quote_uuid')->firstOrFail()->meta;
+
+ ServiceQuoteProviderControllerStub::$failure = new RuntimeException('Network fallback unavailable');
+ $failure = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', $requestData));
+ ServiceQuoteProviderControllerStub::$failure = null;
+
+ expect($resource)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\ServiceQuote::class)
+ ->and($quoteMeta['origin'])->toBe(['place_origin'])
+ ->and($quoteMeta['destination'])->toBe('place_destination')
+ ->and($failure->getData(true))->toBe(['error' => 'Network fallback unavailable']);
+});
diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php
new file mode 100644
index 00000000..a987489e
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php
@@ -0,0 +1,978 @@
+forceFill(['uuid' => 'company_uuid']);
+ $user = new class($company) {
+ public function __construct(public Company $company)
+ {
+ }
+ };
+ $request = Request::create('/metrics', 'GET', [
+ 'start' => '2026-07-01',
+ 'end' => '2026-07-31',
+ 'discover' => ['unknown_metric'],
+ ]);
+ $request->setUserResolver(fn () => $user);
+
+ $response = (new MetricsController())->all($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toBe([]);
+});
+
+test('metrics controller converts metric discovery failures into API errors', function () {
+ Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->dropIfExists('products');
+ $company = new Company();
+ $company->forceFill(['uuid' => 'company_uuid']);
+ $user = new class($company) {
+ public function __construct(public Company $company)
+ {
+ }
+ };
+ $request = Request::create('/metrics', 'GET', [
+ 'discover' => ['totalProducts'],
+ ]);
+ $request->setUserResolver(fn () => $user);
+
+ $response = (new MetricsController())->all($request);
+
+ expect($response->getStatusCode())->toBe(400)
+ ->and($response->getData(true))->toHaveKey('error');
+});
+
+test('action controller returns stable default metrics without an active store', function () {
+ $response = (new ActionController())->getMetrics(Request::create('/metrics'));
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true))->toBe([
+ 'orders_count' => 0,
+ 'customers_count' => 0,
+ 'stores_count' => 0,
+ 'earnings_sum' => 0,
+ ]);
+});
+
+test('action controller validates promotional notification requests before querying customers', function () {
+ $controller = new ActionController();
+
+ $missingContent = $controller->sendPushNotification(Request::create('/notifications', 'POST'));
+ $missingTargets = $controller->sendPushNotification(Request::create('/notifications', 'POST', [
+ 'title' => 'New menu',
+ 'body' => 'Try our latest items.',
+ ]));
+
+ expect($missingContent->getStatusCode())->toBe(400)
+ ->and($missingContent->getData(true))->toBe(['error' => 'Title and body are required'])
+ ->and($missingTargets->getStatusCode())->toBe(400)
+ ->and($missingTargets->getData(true))->toBe(['error' => 'At least one customer must be selected']);
+});
+
+test('action controller counts company stores and rejects an unknown notification store', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id');
+ $table->string('company_uuid');
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ ['public_id' => 'store_one', 'company_uuid' => 'company_uuid'],
+ ['public_id' => 'store_two', 'company_uuid' => 'company_uuid'],
+ ['public_id' => 'store_other', 'company_uuid' => 'other_company'],
+ ]);
+ session(['company' => 'company_uuid']);
+
+ $controller = new ActionController();
+ $count = $controller->getStoreCount(Request::create('/stores/count'));
+ $missingMetrics = $controller->getMetrics(Request::create('/metrics', 'GET', [
+ 'store' => 'missing_store_uuid',
+ ]));
+ $notFound = $controller->sendPushNotification(Request::create('/notifications', 'POST', [
+ 'title' => 'New menu',
+ 'body' => 'Try our latest items.',
+ 'select_all' => true,
+ 'store' => 'missing_store',
+ ]));
+
+ expect($count->getData(true))->toBe(['storeCount' => 2])
+ ->and($missingMetrics->getData(true))->toBe([
+ 'orders_count' => 0,
+ 'customers_count' => 0,
+ 'stores_count' => 0,
+ 'earnings_sum' => 0,
+ ])
+ ->and($notFound->getStatusCode())->toBe(404)
+ ->and($notFound->getData(true))->toBe(['error' => 'Store not found']);
+});
+
+test('action controller reports scoped order customer store and earnings metrics', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['transactions', 'orders', 'contacts', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('orders', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('customer_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('status')->nullable();
+ $table->string('transaction_uuid')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('transactions', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->unsignedBigInteger('amount')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_uuid', 'public_id' => 'store_public', 'company_uuid' => 'company_uuid', 'currency' => 'USD'],
+ ['uuid' => 'second_store_uuid', 'public_id' => 'store_second', 'company_uuid' => 'company_uuid', 'currency' => 'USD'],
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'customer_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ ]);
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => 'order_paid',
+ 'company_uuid' => 'company_uuid',
+ 'customer_uuid' => 'customer_uuid',
+ 'transaction_uuid' => null,
+ 'type' => 'storefront',
+ 'status' => 'completed',
+ 'meta' => json_encode(['storefront_id' => 'store_public', 'total' => 1250]),
+ 'created_at' => '2026-07-15 12:00:00',
+ 'updated_at' => '2026-07-15 12:00:00',
+ ],
+ [
+ 'uuid' => 'order_canceled',
+ 'company_uuid' => 'company_uuid',
+ 'customer_uuid' => 'customer_uuid',
+ 'transaction_uuid' => null,
+ 'type' => 'storefront',
+ 'status' => 'canceled',
+ 'meta' => json_encode(['storefront_id' => 'store_public', 'total' => 500]),
+ 'created_at' => '2026-07-16 12:00:00',
+ 'updated_at' => '2026-07-16 12:00:00',
+ ],
+ [
+ 'uuid' => 'order_late_end_date',
+ 'company_uuid' => 'company_uuid',
+ 'customer_uuid' => 'customer_uuid',
+ 'type' => 'storefront',
+ 'status' => 'dispatched',
+ 'transaction_uuid' => 'transaction_late',
+ 'meta' => json_encode(['storefront_id' => 'store_public']),
+ 'created_at' => '2026-07-31 23:59:59',
+ 'updated_at' => '2026-07-31 23:59:59',
+ ],
+ ]);
+ $connection->table('transactions')->insert([
+ 'uuid' => 'transaction_late',
+ 'amount' => 750,
+ 'currency' => 'USD',
+ ]);
+ session(['company' => 'company_uuid']);
+
+ $response = (new ActionController())->getMetrics(Request::create('/metrics', 'GET', [
+ 'store' => 'store_uuid',
+ 'start' => '2026-07-01',
+ 'end' => '2026-07-31',
+ ]));
+
+ expect($response->getData(true))->toBe([
+ 'orders_count' => 2,
+ 'customers_count' => 1,
+ 'stores_count' => 2,
+ 'earnings_sum' => 2000,
+ 'currency' => 'USD',
+ ]);
+});
+
+test('action controller sends selected and all-customer promotions while isolating delivery failures', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('contacts');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Test Store',
+ ]);
+ $connection->table('contacts')->insert([
+ ['uuid' => 'customer_selected', 'company_uuid' => 'company_uuid', 'type' => 'customer'],
+ ['uuid' => 'other_company_customer', 'company_uuid' => 'other_company', 'type' => 'customer'],
+ ]);
+ session(['company' => 'company_uuid']);
+ $controller = new ActionController();
+ $selected = $controller->sendPushNotification(Request::create('/notifications', 'POST', [
+ 'title' => 'New menu',
+ 'body' => 'Try our latest items.',
+ 'customers' => ['customer_selected'],
+ 'store' => 'store_public',
+ ]));
+
+ app()->instance(
+ Illuminate\Contracts\Notifications\Dispatcher::class,
+ new class implements Illuminate\Contracts\Notifications\Dispatcher {
+ public function send($notifiables, $notification)
+ {
+ }
+
+ public function sendNow($notifiables, $notification, ?array $channels = null)
+ {
+ }
+ }
+ );
+ $all = $controller->sendPushNotification(Request::create('/notifications', 'POST', [
+ 'title' => 'New menu',
+ 'body' => 'Try our latest items.',
+ 'select_all' => true,
+ 'store' => 'store_public',
+ ]));
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+
+ expect($selected->getData(true))->toBe([
+ 'status' => 'OK',
+ 'sent_count' => 0,
+ 'total' => 1,
+ ])->and($all->getData(true))->toBe([
+ 'status' => 'OK',
+ 'sent_count' => 1,
+ 'total' => 1,
+ ]);
+});
+
+test('public catalog and food truck queries are empty without a storefront store context', function () {
+ session(['storefront_store' => null]);
+
+ $catalogs = (new CatalogController())->query(Request::create('/catalogs'));
+ $foodTrucks = (new FoodTruckController())->query(Request::create('/food-trucks'));
+
+ expect($catalogs)->toBe([])
+ ->and($foodTrucks->resource)->toBeEmpty();
+});
+
+test('public catalog query scopes persisted catalogs to the active store with pagination controls', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('catalogs');
+ $schema->create('catalogs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('name');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('catalogs')->insert([
+ ['uuid' => 'catalog_first', 'store_uuid' => 'store_uuid', 'name' => 'Breakfast'],
+ ['uuid' => 'catalog_second', 'store_uuid' => 'store_uuid', 'name' => 'Lunch'],
+ ['uuid' => 'catalog_other', 'store_uuid' => 'other_store', 'name' => 'Other'],
+ ]);
+ session(['storefront_store' => 'store_uuid']);
+
+ $request = Request::create('/catalogs', 'GET', [
+ 'limit' => 1,
+ 'offset' => 1,
+ ]);
+ $request->setLaravelSession(request()->session());
+ $results = (new CatalogController())->query($request);
+
+ expect($results)->toHaveCount(1)
+ ->and($results->first()->uuid)->toBe('catalog_second');
+});
+
+test('public food truck lookup reports an unknown resource', function () {
+ foreach (['storefront', 'mysql'] as $connectionName) {
+ $schema = Model::getConnectionResolver()->connection($connectionName)->getSchemaBuilder();
+ $schema->dropIfExists('food_trucks');
+ $schema->create('food_trucks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ }
+ session(['company' => null]);
+
+ $response = (new FoodTruckController())->find('missing_food_truck');
+
+ expect($response->getStatusCode())->toBe(400)
+ ->and($response->getData(true))->toBe(['error' => 'Food Truck resource not found.']);
+});
+
+test('public food truck query and lookup return records for the active store', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('food_trucks');
+ $schema->create('food_trucks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid');
+ $table->string('vehicle_uuid')->nullable();
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('food_trucks')->insert([
+ ['uuid' => 'truck_first', 'public_id' => 'food_truck_first', 'store_uuid' => 'store_uuid', 'status' => 'online'],
+ ['uuid' => 'truck_second', 'public_id' => 'food_truck_second', 'store_uuid' => 'store_uuid', 'status' => 'offline'],
+ ['uuid' => 'truck_other', 'public_id' => 'food_truck_other', 'store_uuid' => 'other_store', 'status' => 'online'],
+ ]);
+ session(['storefront_store' => 'store_uuid']);
+ $request = Request::create('/food-trucks', 'GET', ['limit' => 1, 'offset' => 1]);
+ $request->setLaravelSession(request()->session());
+ $controller = new FoodTruckController();
+
+ $results = $controller->query($request);
+ $found = $controller->find('food_truck_first');
+
+ expect($results->resource)->toHaveCount(1)
+ ->and($results->resource->first()->uuid)->toBe('truck_second')
+ ->and($found->resource->uuid)->toBe('truck_first');
+});
+
+test('store controller reports missing storefront context and lookup identifiers', function () {
+ session(['storefront_key' => null]);
+ $controller = new StoreController();
+
+ $about = $controller->about();
+ $lookup = $controller->lookup(null);
+
+ expect($about->getStatusCode())->toBe(400)
+ ->and($about->getData(true))->toBe(['error' => 'Unable to find store!'])
+ ->and($lookup->getStatusCode())->toBe(400)
+ ->and($lookup->getData(true))->toBe(['error' => 'No ID provided for lookup.']);
+});
+
+test('store controller returns store and network resources for active contexts and company lookups', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('networks');
+ foreach (['stores', 'networks'] as $tableName) {
+ $schema->create($tableName, function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->string('currency')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'network_key',
+ 'name' => 'Test network',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ ]);
+ $controller = new StoreController();
+
+ $storeAbout = $controller->about();
+ $storeLookup = $controller->lookup('store_abcdefgh');
+ $networkLookup = $controller->lookup('network_abcdefgh');
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $networkAbout = $controller->about();
+
+ expect($storeAbout)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Store::class)
+ ->and($storeAbout->resource->uuid)->toBe('store_uuid')
+ ->and($storeLookup)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Store::class)
+ ->and($storeLookup->resource->public_id)->toBe('store_abcdefgh')
+ ->and($networkLookup)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Network::class)
+ ->and($networkLookup->resource->public_id)->toBe('network_abcdefgh')
+ ->and($networkAbout)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Network::class)
+ ->and($networkAbout->resource->uuid)->toBe('network_uuid');
+});
+
+test('store controller returns a stable error when no store or network matches lookup', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('networks');
+ foreach (['stores', 'networks'] as $tableName) {
+ $schema->create($tableName, function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('company_uuid');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ }
+ session(['company' => 'company_uuid']);
+
+ $response = (new StoreController())->lookup('missing_public_id');
+
+ expect($response->getStatusCode())->toBe(400)
+ ->and($response->getData(true))->toBe([
+ 'error' => 'Unable to find store or network for ID provided.',
+ ]);
+});
+
+test('store controller rejects location access for networks without an explicit store', function () {
+ session(['storefront_network' => 'network_uuid']);
+ $request = Request::create('/v1/storefront/locations');
+
+ $locations = (new StoreController())->locations($request);
+ $location = (new StoreController())->location('location_public', $request);
+
+ expect($locations->getStatusCode())->toBe(400)
+ ->and($locations->getData(true))->toBe(['error' => 'Networks cannot have locations!'])
+ ->and($location->getStatusCode())->toBe(400)
+ ->and($location->getData(true))->toBe(['error' => 'Networks cannot have locations!']);
+});
+
+test('store controller resolves active and explicitly selected store locations with their relations', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['store_hours', 'store_locations', 'places', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('address')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('store_hours', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('store_location_uuid')->nullable();
+ $table->integer('day_of_week')->nullable();
+ $table->string('start')->nullable();
+ $table->string('end')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ ]);
+ $connection->table('places')->insert([
+ 'uuid' => 'place_uuid',
+ 'public_id' => 'place_abcdefgh',
+ 'address' => '1 Fleet Street',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_abcdefgh',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'place_uuid',
+ 'name' => 'Main location',
+ ]);
+ $connection->table('store_hours')->insert([
+ 'uuid' => 'hours_uuid',
+ 'store_location_uuid' => 'location_uuid',
+ 'day_of_week' => 1,
+ 'start' => '09:00',
+ 'end' => '17:00',
+ ]);
+ session([
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $controller = new StoreController();
+
+ $activeLocations = $controller->locations(Request::create('/locations'));
+ $selectedLocations = $controller->locations(Request::create('/locations', 'GET', [
+ 'store' => 'store_abcdefgh',
+ ]));
+ $location = $controller->location(
+ 'store_location_abcdefgh',
+ Request::create('/locations/store_location_abcdefgh')
+ );
+
+ expect($activeLocations->resource)->toHaveCount(1)
+ ->and($activeLocations->resource->first()->uuid)->toBe('location_uuid')
+ ->and($activeLocations->resource->first()->place->uuid)->toBe('place_uuid')
+ ->and($activeLocations->resource->first()->hours)->toHaveCount(1)
+ ->and($selectedLocations->resource)->toHaveCount(1)
+ ->and($selectedLocations->resource->first()->uuid)->toBe('location_uuid')
+ ->and($location->resource->uuid)->toBe('location_uuid');
+});
+
+test('store controller searches direct and category products across store and network contexts', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['network_stores', 'networks', 'categories', 'products', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('sku')->nullable();
+ $table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_abcdefgh',
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ ]);
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_abcdefgh',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Coffee',
+ 'description' => 'Coffee products',
+ 'for' => 'storefront_product',
+ ]);
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'direct_product_uuid',
+ 'public_id' => 'product_abcdefgh',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => null,
+ 'name' => 'Coffee beans',
+ 'description' => 'Fresh roast',
+ 'sku' => 'COFFEE-1',
+ 'is_available' => true,
+ 'status' => 'published',
+ ],
+ [
+ 'uuid' => 'category_product_uuid',
+ 'public_id' => 'product_ijklmnop',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'category_uuid',
+ 'name' => 'Tea',
+ 'description' => 'Category-associated product',
+ 'sku' => 'TEA-1',
+ 'is_available' => true,
+ 'status' => 'published',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
+ $controller = new StoreController();
+
+ $storeResults = $controller->search(Request::create('/search', 'GET', [
+ 'query' => 'Coffee',
+ 'limit' => 10,
+ ]));
+ session([
+ 'storefront_key' => 'network_key',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $networkResults = $controller->search(Request::create('/search', 'GET', [
+ 'query' => 'Coffee',
+ 'store' => 'store_abcdefgh',
+ 'limit' => 10,
+ ]));
+
+ expect($storeResults->resource->pluck('uuid')->all())->toBe([
+ 'direct_product_uuid',
+ 'category_product_uuid',
+ ])->and($networkResults->resource->pluck('uuid')->all())->toBe([
+ 'direct_product_uuid',
+ ]);
+});
+
+test('network controller resolves public IDs and invitation codes to their network', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('invites');
+ $schema->dropIfExists('network_stores');
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('networks');
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('invites', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('uri')->nullable();
+ $table->string('reason')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'name' => 'Delivery network',
+ ]);
+ $connection->table('invites')->insert([
+ 'uuid' => 'invite_uuid',
+ 'public_id' => 'invite_abcdefgh',
+ 'uri' => 'join-code',
+ 'reason' => 'join_storefront_network',
+ 'subject_uuid' => 'network_uuid',
+ 'subject_type' => Fleetbase\Storefront\Models\Network::class,
+ ]);
+ $environmentRepository = new class {
+ public function get(string $key): ?string
+ {
+ return $key === 'DB_CONNECTION' ? 'mysql' : null;
+ }
+ };
+ $repository = new ReflectionProperty(Illuminate\Support\Env::class, 'repository');
+ $repository->setValue(null, $environmentRepository);
+ $controller = new NetworkController();
+
+ $byPublicId = $controller->findNetwork(' network_abcdefgh ');
+ $byInvite = $controller->findNetwork('join-code');
+ $missing = $controller->findNetwork('unknown-code');
+ $publicNetwork = $byPublicId->getOriginalContent();
+ $invitedNetwork = $byInvite->getOriginalContent();
+
+ expect($publicNetwork)->toBeInstanceOf(Fleetbase\Storefront\Models\Network::class)
+ ->and($publicNetwork->uuid)->toBe('network_uuid')
+ ->and($publicNetwork->public_id)->toBe('network_abcdefgh')
+ ->and($publicNetwork->name)->toBe('Delivery network')
+ ->and($invitedNetwork)->toBeInstanceOf(Fleetbase\Storefront\Models\Network::class)
+ ->and($invitedNetwork->uuid)->toBe('network_uuid')
+ ->and($missing->getData(true))->toBe([]);
+});
+
+test('network controller persists and sends an email invitation with its request actor', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('invites');
+ $schema->dropIfExists('networks');
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('invites', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('protocol')->nullable();
+ $table->text('recipients')->nullable();
+ $table->string('reason')->nullable();
+ $table->string('uri')->nullable();
+ $table->string('code')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('networks')->insert([
+ 'id' => 1,
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'name' => 'Delivery network',
+ ]);
+ session(['company' => 'company_uuid', 'user' => 'user_uuid']);
+ $sender = new User();
+ $sender->forceFill([
+ 'uuid' => 'user_uuid',
+ 'public_id' => 'user_abcdefgh',
+ 'name' => 'Network owner',
+ 'email' => 'owner@example.test',
+ ]);
+ $mail = new class {
+ public array $sent = [];
+
+ public function send($mailable): void
+ {
+ $this->sent[] = $mailable;
+ }
+ };
+ Mail::swap($mail);
+ $environmentRepository = new class {
+ public function get(string $key): ?string
+ {
+ return $key === 'DB_CONNECTION' ? 'mysql' : null;
+ }
+ };
+ $repository = new ReflectionProperty(Illuminate\Support\Env::class, 'repository');
+ $repository->setValue(null, $environmentRepository);
+ $request = NetworkActionRequest::create('/network/invites', 'POST', [
+ 'recipients' => ['store@example.test'],
+ ]);
+ $request->setUserResolver(fn () => $sender);
+
+ $response = (new NetworkController())->sendInvites('network_uuid', $request);
+ $invite = Invite::query()->firstOrFail();
+
+ expect($response->getData(true))->toBe(['status' => 'ok'])
+ ->and($invite->company_uuid)->toBe('company_uuid')
+ ->and($invite->created_by_uuid)->toBe('user_uuid')
+ ->and($invite->subject_uuid)->toBe('network_uuid')
+ ->and($invite->protocol)->toBe('email')
+ ->and($invite->recipients)->toBe(['store@example.test'])
+ ->and($mail->sent)->toHaveCount(1)
+ ->and($mail->sent[0])->toBeInstanceOf(Fleetbase\Storefront\Mail\StorefrontNetworkInvite::class)
+ ->and($mail->sent[0]->network->uuid)->toBe('network_uuid')
+ ->and($mail->sent[0]->sender)->toBe($sender);
+});
+
+test('network controller adds removes and categorizes store assignments', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('networks');
+ $schema->dropIfExists('network_stores');
+ $schema->dropIfExists('categories');
+ $schema->create('networks', function ($table) {
+ $table->string('uuid')->primary();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('owner_uuid');
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'owner_uuid' => 'network_uuid',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $controller = new NetworkController();
+ $addRequest = NetworkActionRequest::create('/network', 'POST', [
+ 'stores' => ['store_a', 'store_b'],
+ ]);
+
+ expect($controller->addStores('network_uuid', $addRequest)->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('network_stores')->whereNull('deleted_at')->count())->toBe(2);
+
+ $categoryRequest = AddStoreToNetworkCategory::create('/network', 'POST', [
+ 'store' => 'store_a',
+ 'category' => 'category_uuid',
+ ]);
+ expect($controller->addStoreToCategory('network_uuid', $categoryRequest)->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_a')->value('category_uuid'))->toBe('category_uuid');
+
+ $removeCategoryRequest = NetworkActionRequest::create('/network', 'POST', ['store' => 'store_a']);
+ expect($controller->removeStoreCategory('network_uuid', $removeCategoryRequest)->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_a')->value('category_uuid'))->toBeNull();
+
+ $deleteCategoryRequest = NetworkActionRequest::create('/network', 'POST', ['category' => 'category_uuid']);
+ expect($controller->deleteCategory('network_uuid', $deleteCategoryRequest)->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('categories')->where('uuid', 'category_uuid')->value('deleted_at'))->not->toBeNull();
+
+ $removeRequest = NetworkActionRequest::create('/network', 'POST', ['stores' => ['store_a']]);
+ expect($controller->removeStores('network_uuid', $removeRequest)->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_a')->value('deleted_at'))->not->toBeNull();
+});
+
+test('network controller add stores removes requested stale assignments in the same operation', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('networks');
+ $schema->dropIfExists('network_stores');
+ $schema->create('networks', function ($table) {
+ $table->string('uuid')->primary();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_old',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $request = NetworkActionRequest::create('/network', 'POST', [
+ 'stores' => ['store_new'],
+ 'remove' => ['store_old'],
+ ]);
+ $response = (new NetworkController())->addStores('network_uuid', $request);
+
+ expect($response->getData(true))->toBe(['status' => 'ok'])
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_new')->whereNull('deleted_at')->exists())->toBeTrue()
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_old')->whereNotNull('deleted_at')->exists())->toBeTrue();
+});
diff --git a/server/tests/Unit/Http/Controllers/StoreGatewayControllerContractsTest.php b/server/tests/Unit/Http/Controllers/StoreGatewayControllerContractsTest.php
new file mode 100644
index 00000000..820c5356
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/StoreGatewayControllerContractsTest.php
@@ -0,0 +1,206 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('gateways');
+
+ $schema->create('stores', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid',
+ 'order_config_uuid', 'key', 'name', 'description', 'translations',
+ 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone',
+ 'tags', 'currency', 'timezone', 'pod_method', 'options',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('gateways', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('code')->nullable();
+ $table->string('type')->nullable();
+ $table->boolean('sandbox')->default(false);
+ $table->text('return_url')->nullable();
+ $table->text('callback_url')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+test('store gateway listing scopes owner sandbox mode and configured cash availability', function () {
+ createStoreGatewayControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ 'options' => json_encode(['cod_enabled' => true]),
+ ]);
+ $connection->table('gateways')->insert([
+ [
+ 'uuid' => 'live_uuid',
+ 'public_id' => 'gateway_live',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Live Stripe',
+ 'code' => 'stripe',
+ 'type' => 'stripe',
+ 'sandbox' => false,
+ ],
+ [
+ 'uuid' => 'sandbox_uuid',
+ 'public_id' => 'gateway_sandbox',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Sandbox Stripe',
+ 'code' => 'stripe-test',
+ 'type' => 'stripe',
+ 'sandbox' => true,
+ ],
+ [
+ 'uuid' => 'other_uuid',
+ 'public_id' => 'gateway_other',
+ 'owner_uuid' => 'other_store',
+ 'name' => 'Other Store',
+ 'code' => 'other',
+ 'type' => 'other',
+ 'sandbox' => true,
+ ],
+ ]);
+ session([
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ ]);
+
+ $controller = new StoreController();
+ $all = $controller->gateways(Request::create('/gateways'));
+ $sandbox = $controller->gateways(Request::create('/gateways', 'GET', ['sandbox' => true]));
+
+ expect($all->collection->pluck('public_id')->all())->toBe([
+ 'gateway_live',
+ 'gateway_sandbox',
+ 'gateway_cash',
+ ])->and($sandbox->collection->pluck('public_id')->all())->toBe([
+ 'gateway_sandbox',
+ 'gateway_cash',
+ ])->and($sandbox->collection->last()->sandbox)->toBeTrue();
+});
+
+test('store gateway lookup accepts public identifiers and provider codes with sandbox filtering', function () {
+ createStoreGatewayControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'public_id' => 'gateway_public',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Sandbox QPay',
+ 'code' => 'qpay',
+ 'type' => 'qpay',
+ 'sandbox' => true,
+ ]);
+ session(['storefront_store' => 'store_uuid']);
+ $controller = new StoreController();
+
+ $byId = $controller->gateway('gateway_public', Request::create('/gateway'));
+ $byCode = $controller->gateway('qpay', Request::create('/gateway', 'GET', ['sandbox' => true]));
+ $hidden = $controller->gateway('qpay', Request::create('/gateway', 'GET', ['sandbox' => false]));
+
+ expect($byId->resource?->public_id)->toBe('gateway_public')
+ ->and($byCode->resource?->public_id)->toBe('gateway_public')
+ ->and($hidden->resource?->public_id)->toBe('gateway_public');
+});
+
+test('store search returns only published available products for the active store', function () {
+ $productSchema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ $productSchema->dropIfExists('products');
+ $productSchema->create('products', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->boolean('is_available')->default(true);
+ $table->string('status')->default('published');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $categorySchema = $productSchema;
+ $categorySchema->dropIfExists('categories');
+ $categorySchema->create('categories', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('company_uuid')->nullable();
+ $table->string('for')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Model::getConnectionResolver()->connection('mysql')->table('products')->insert([
+ [
+ 'uuid' => 'matching_uuid',
+ 'public_id' => 'product_matching',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Cold Brew',
+ 'description' => 'Fresh coffee',
+ 'is_available' => true,
+ 'status' => 'published',
+ ],
+ [
+ 'uuid' => 'unavailable_uuid',
+ 'public_id' => 'product_unavailable',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Cold Brew unavailable',
+ 'description' => null,
+ 'is_available' => false,
+ 'status' => 'published',
+ ],
+ [
+ 'uuid' => 'draft_uuid',
+ 'public_id' => 'product_draft',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Cold Brew draft',
+ 'description' => null,
+ 'is_available' => true,
+ 'status' => 'draft',
+ ],
+ [
+ 'uuid' => 'other_uuid',
+ 'public_id' => 'product_other',
+ 'store_uuid' => 'other_store',
+ 'name' => 'Cold Brew elsewhere',
+ 'description' => null,
+ 'is_available' => true,
+ 'status' => 'published',
+ ],
+ ]);
+ session([
+ 'storefront_key' => 'store_key',
+ 'storefront_store' => 'store_uuid',
+ 'company' => 'company_uuid',
+ ]);
+
+ $results = (new StoreController())->search(Request::create('/search', 'GET', [
+ 'query' => 'cold brew',
+ 'limit' => 2,
+ ]));
+
+ expect($results->collection->pluck('public_id')->all())->toBe(['product_matching']);
+});
diff --git a/server/tests/Unit/Http/Controllers/UtilityControllerContractsTest.php b/server/tests/Unit/Http/Controllers/UtilityControllerContractsTest.php
new file mode 100644
index 00000000..46de6841
--- /dev/null
+++ b/server/tests/Unit/Http/Controllers/UtilityControllerContractsTest.php
@@ -0,0 +1,79 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('reviews');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('company_uuid');
+ $table->string('name');
+ $table->string('description')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('reviews', function ($table) {
+ $table->increments('id');
+ $table->string('subject_uuid');
+ $table->integer('rating');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ Store::withoutEvents(function () use ($connection) {
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'store_one',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Company store',
+ 'description' => 'Visible',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'store_other',
+ 'company_uuid' => 'other_company',
+ 'name' => 'Other store',
+ 'description' => 'Hidden',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ });
+ $request = Request::create('/stores');
+ $session = new SessionStore('storefront-controller-test', new ArraySessionHandler(120));
+ $session->put('company', 'company_uuid');
+ $request->setLaravelSession($session);
+
+ $response = (new StoreController())->allStores($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($response->getData(true)['stores'])->toHaveCount(1)
+ ->and($response->getData(true)['stores'][0]['name'])->toBe('Company store');
+});
+
+test('food truck controller adds vehicle eager loading to record queries', function () {
+ $builder = FoodTruck::query();
+
+ (new FoodTruckController())->onQueryRecord($builder);
+
+ expect($builder->getEagerLoads())->toHaveKey('vehicle');
+});
+
+test('products import returns the heading-row collection unchanged', function () {
+ $rows = new Collection([['name' => 'Coffee'], ['name' => 'Tea']]);
+ $import = new ProductsImport();
+
+ expect($import->collection($rows))->toBe($rows);
+});
diff --git a/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php b/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php
new file mode 100644
index 00000000..73c56f18
--- /dev/null
+++ b/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php
@@ -0,0 +1,265 @@
+setLaravelSession(request()->session());
+ $request->session()->put('company', 'company_uuid');
+ $request->setRouteResolver(fn () => new class($uri) {
+ public array $action = [];
+
+ public function __construct(private string $routeUri)
+ {
+ }
+
+ public function uri(): string
+ {
+ return $this->routeUri;
+ }
+ });
+
+ return $request;
+}
+
+function applyStorefrontFilter(string $filter, Builder $builder, string $uri, array $query = []): Builder
+{
+ return (new $filter(storefrontFilterRequest($uri, $query)))->apply($builder);
+}
+
+test('company-scoped filters isolate internal resources to the active tenant', function ($filter, $model) {
+ $builder = applyStorefrontFilter($filter, (new $model())->newQuery(), 'int/v1/storefront/resources');
+
+ expect($builder->toSql())->toContain('"company_uuid" = ?')
+ ->and($builder->getBindings())->toContain('company_uuid');
+})->with([
+ 'gateway' => [GatewayFilter::class, Gateway::class],
+ 'network' => [NetworkFilter::class, Network::class],
+ 'notification channel' => [NotificationChannelFilter::class, NotificationChannel::class],
+ 'product' => [ProductFilter::class, Product::class],
+ 'store' => [StoreFilter::class, Store::class],
+]);
+
+test('addon category filter enforces company and storefront addon purpose', function () {
+ $builder = applyStorefrontFilter(
+ AddonCategoryFilter::class,
+ (new AddonCategory())->newQuery(),
+ 'int/v1/storefront/addon-categories'
+ );
+
+ expect($builder->toSql())->toContain('"company_uuid" = ?')
+ ->and($builder->toSql())->toContain('"for" = ?')
+ ->and($builder->getBindings())->toContain('company_uuid', 'storefront_product_addon');
+});
+
+test('network product and store search filters apply their searchable columns', function () {
+ $network = applyStorefrontFilter(
+ NetworkFilter::class,
+ (new Network())->newQuery(),
+ 'v1/storefront/networks',
+ ['query' => 'coffee']
+ );
+ $product = applyStorefrontFilter(
+ ProductFilter::class,
+ (new Product())->newQuery(),
+ 'v1/storefront/products',
+ ['query' => 'coffee']
+ );
+ $store = applyStorefrontFilter(
+ StoreFilter::class,
+ (new Store())->newQuery(),
+ 'v1/storefront/stores',
+ ['store_query' => 'coffee']
+ );
+
+ expect($network->toSql())->toContain('lower(name) like ?')
+ ->and($product->toSql())->toContain('lower(name) like ?')
+ ->and($store->toSql())->toContain('lower(name) like ?')
+ ->and($network->getBindings())->toContain('%coffee%')
+ ->and($product->getBindings())->toContain('%coffee%')
+ ->and($store->getBindings())->toContain('%coffee%');
+});
+
+test('product category slug filter resolves known categories and ignores unknown slugs', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('categories');
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('slug');
+ $table->string('for');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'slug' => 'coffee',
+ 'for' => 'storefront_product',
+ ]);
+
+ $known = applyStorefrontFilter(
+ ProductFilter::class,
+ (new Product())->newQuery(),
+ 'v1/storefront/products',
+ ['category_slug' => 'coffee']
+ );
+ $unknown = applyStorefrontFilter(
+ ProductFilter::class,
+ (new Product())->newQuery(),
+ 'v1/storefront/products',
+ ['category_slug' => 'unknown']
+ );
+
+ expect($known->toSql())->toContain('"category_uuid" = ?')
+ ->and($known->getBindings())->toContain('category_uuid')
+ ->and($unknown->toSql())->not->toContain('"category_uuid" = ?');
+});
+
+test('customer filter constrains customers through storefront order metadata', function () {
+ $builder = applyStorefrontFilter(
+ CustomerFilter::class,
+ (new Customer())->newQuery(),
+ 'v1/storefront/customers',
+ ['storefront' => 'store_public']
+ );
+
+ expect($builder->toSql())->toContain('exists')
+ ->and($builder->toSql())->toContain('json_extract')
+ ->and($builder->getBindings())->toContain('store_public');
+});
+
+test('food truck filters enforce tenant vehicle storefront and deleted-record contracts', function () {
+ $internal = applyStorefrontFilter(
+ FoodTruckFilter::class,
+ (new FoodTruck())->newQuery(),
+ 'int/v1/storefront/food-trucks'
+ );
+ $public = applyStorefrontFilter(
+ FoodTruckFilter::class,
+ (new FoodTruck())->newQuery(),
+ 'v1/storefront/food-trucks',
+ ['storefront' => 'store_public', 'with_deleted' => true]
+ );
+
+ expect($internal->toSql())->toContain('"company_uuid" = ?')
+ ->and($public->toSql())->toContain('exists')
+ ->and($public->toSql())->toContain('"public_id" = ?')
+ ->and($public->getBindings())->toContain('company_uuid', 'store_public')
+ ->and($public->toSql())->not->toContain('"food_trucks"."deleted_at" is null');
+});
+
+test('food truck service-area filter resolves public ids and UUIDs before constraining trucks', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('service_areas');
+ $schema->create('service_areas', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('service_areas')->insert([
+ 'uuid' => 'service_area_uuid',
+ 'public_id' => 'service_area_public',
+ ]);
+
+ $builder = applyStorefrontFilter(
+ FoodTruckFilter::class,
+ (new FoodTruck())->newQuery(),
+ 'v1/storefront/food-trucks',
+ ['service_area' => 'service_area_public']
+ );
+
+ expect($builder->toSql())->toContain('"service_area_uuid" in (?)')
+ ->and($builder->getBindings())->toContain('service_area_uuid');
+});
+
+test('store filter supports uncategorized parent and explicit network categories', function () {
+ $withoutCategory = applyStorefrontFilter(
+ StoreFilter::class,
+ (new Store())->newQuery(),
+ 'v1/storefront/stores',
+ ['network' => 'network_uuid', 'without_category' => true]
+ );
+ $parent = applyStorefrontFilter(
+ StoreFilter::class,
+ (new Store())->newQuery(),
+ 'v1/storefront/stores',
+ ['network' => 'network_uuid', 'category' => '_parent']
+ );
+ $category = applyStorefrontFilter(
+ StoreFilter::class,
+ (new Store())->newQuery(),
+ 'v1/storefront/stores',
+ ['network' => 'network_uuid', 'category' => 'category_uuid']
+ );
+
+ expect($withoutCategory->toSql())->toContain('"category_uuid" is null')
+ ->and($parent->toSql())->toContain('"category_uuid" is null')
+ ->and($category->toSql())->toContain('"category_uuid" = ?')
+ ->and($category->getBindings())->toContain('network_uuid', 'category_uuid');
+});
+
+test('store location filters scope internal records through stores and direct store ids', function () {
+ $internal = applyStorefrontFilter(
+ StoreLocationFilter::class,
+ (new StoreLocation())->newQuery(),
+ 'int/v1/storefront/store-locations'
+ );
+ $public = applyStorefrontFilter(
+ StoreLocationFilter::class,
+ (new StoreLocation())->newQuery(),
+ 'v1/storefront/store-locations',
+ ['store' => 'store_uuid']
+ );
+
+ expect($internal->toSql())->toContain('exists')
+ ->and($internal->getBindings())->toContain('company_uuid')
+ ->and($public->toSql())->toContain('"store_uuid" = ?')
+ ->and($public->getBindings())->toContain('store_uuid');
+});
+
+test('storefront order filter applies storefront metadata tracking and payload requirements', function () {
+ $builder = applyStorefrontFilter(
+ OrderFilter::class,
+ (new Order())->newQuery(),
+ 'int/v1/storefront/orders',
+ ['storefront' => 'store_public']
+ );
+
+ expect($builder->toSql())->toContain('"company_uuid" = ?')
+ ->and($builder->toSql())->toContain('json_extract')
+ ->and($builder->toSql())->toContain('exists')
+ ->and($builder->getBindings())->toContain('company_uuid', 'store_public')
+ ->and(array_keys($builder->getEagerLoads()))->toContain(
+ 'payload.entities',
+ 'payload.waypoints',
+ 'payload.pickup',
+ 'payload.dropoff',
+ 'trackingNumber',
+ 'trackingStatuses',
+ 'driverAssigned'
+ );
+});
diff --git a/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php
new file mode 100644
index 00000000..c63951a2
--- /dev/null
+++ b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php
@@ -0,0 +1,186 @@
+ response()->json(['ok' => true]);
+
+ $missing = $middleware->handle(Request::create('/storefront'), $next);
+ $invalid = $middleware->handle(
+ Request::create('/storefront', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer invalid_key']),
+ $next
+ );
+
+ expect($missing->getStatusCode())->toBe(401)
+ ->and($missing->getData(true))->toBe(['error' => 'Oops! No Storefront key found with this request'])
+ ->and($invalid->getStatusCode())->toBe(401)
+ ->and($invalid->getData(true))->toBe(['error' => 'Oops! The Storefront key provided was not valid'])
+ ->and($middleware->isValidKey('merchant_key'))->toBeFalse();
+});
+
+test('storefront session middleware validates store credentials and exposes complete store context', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('company_uuid');
+ $table->string('key');
+ $table->string('currency');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_secret',
+ 'currency' => 'USD',
+ ]);
+
+ $middleware = new SetStorefrontSession();
+
+ expect($middleware->isValidKey('store_secret'))->toBeTrue();
+
+ $response = $middleware->handle(
+ Request::create('/storefront', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer store_secret']),
+ fn () => response()->json(['ok' => true])
+ );
+
+ expect($response->getData(true))->toBe(['ok' => true])
+ ->and(session('storefront_key'))->toBe('store_secret')
+ ->and(session('storefront_store'))->toBe('store_uuid')
+ ->and(session('storefront_store_public_id'))->toBe('store_public')
+ ->and(session('storefront_currency'))->toBe('USD')
+ ->and(session('company'))->toBe('company_uuid')
+ ->and(session('api_credential'))->toBe('store_secret');
+});
+
+test('storefront session middleware validates network credentials and exposes complete network context', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('networks');
+ $schema->create('networks', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('company_uuid');
+ $table->string('key');
+ $table->string('currency');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'network_secret',
+ 'currency' => 'MNT',
+ ]);
+
+ $middleware = new SetStorefrontSession();
+
+ expect($middleware->isValidKey('network_secret'))->toBeTrue();
+
+ $middleware->setKey('network_secret');
+
+ expect(session('storefront_network'))->toBe('network_uuid')
+ ->and(session('storefront_network_public_id'))->toBe('network_public')
+ ->and(session('storefront_currency'))->toBe('MNT')
+ ->and(session('api_credential'))->toBe('network_secret');
+});
+
+test('customer setup is a no-op without a customer token', function () {
+ $middleware = new SetStorefrontSession();
+
+ expect($middleware->setupCustomerSession(Request::create('/storefront')))->toBeNull();
+});
+
+test('access token resolution returns an already loaded tokenable model', function () {
+ $tokenable = new class extends Model {
+ };
+ $tokenable->forceFill(['uuid' => 'user_uuid']);
+
+ $token = new PersonalAccessToken();
+ $token->setRelation('tokenable', $tokenable);
+
+ expect((new SetStorefrontSession())->getTokenableFromAccessToken($token))->toBe($tokenable);
+});
+
+test('customer setup resolves an unloaded access token owner and stores its contact identity', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'contacts', 'users'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type')->nullable();
+ $table->string('tokenable_id')->nullable();
+ $table->string('name')->nullable();
+ $table->string('token');
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_abcdefgh',
+ 'user_uuid' => 'user_uuid',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => User::class,
+ 'tokenable_id' => 'user_uuid',
+ 'name' => 'customer access',
+ 'token' => hash('sha256', 'customer-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $request = Request::create('/storefront', 'GET', [], [], [], [
+ 'HTTP_CUSTOMER_TOKEN' => 'customer-secret',
+ ]);
+
+ (new SetStorefrontSession())->setupCustomerSession($request);
+
+ expect(session('customer_id'))->toBe('customer_abcdefgh')
+ ->and(session('contact_id'))->toBe('contact_abcdefgh')
+ ->and(session('customer'))->toBe('contact_uuid');
+
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => User::class,
+ 'tokenable_id' => 'missing_user_uuid',
+ 'name' => 'orphaned customer access',
+ 'token' => hash('sha256', 'orphaned-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $orphanedRequest = Request::create('/storefront', 'GET', [], [], [], [
+ 'HTTP_CUSTOMER_TOKEN' => 'orphaned-secret',
+ ]);
+
+ expect((new SetStorefrontSession())->setupCustomerSession($orphanedRequest))->toBeNull()
+ ->and(session('customer'))->toBe('contact_uuid');
+});
diff --git a/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php b/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php
new file mode 100644
index 00000000..5017817e
--- /dev/null
+++ b/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php
@@ -0,0 +1,28 @@
+setRouteResolver(fn () => new class {
+ public function getDomain(): string
+ {
+ return 'storefront.test';
+ }
+ });
+
+ $response = $middleware->handle(
+ $request,
+ fn () => new JsonResponse(['status' => 'ok']),
+ 1,
+ 99
+ );
+
+ expect($response->getData(true))->toBe(['status' => 'ok'])
+ ->and($response->headers->get('X-RateLimit-Limit'))->toBe('500')
+ ->and($response->headers->get('X-RateLimit-Remaining'))->toBe('499');
+});
diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php
new file mode 100644
index 00000000..160cbeb8
--- /dev/null
+++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php
@@ -0,0 +1,195 @@
+ 'store_public_key']);
+ expect((bool) (new $requestClass())->authorize())->toBeTrue();
+
+ session(['storefront_key' => null]);
+ expect((bool) (new $requestClass())->authorize())->toBeFalse();
+})->with([
+ CaptureOrderRequest::class,
+ CreateStripeSetupIntentRequest::class,
+ GetServiceQuoteFromCart::class,
+ InitializeCheckoutRequest::class,
+ StorefrontCustomerRequest::class,
+]);
+
+test('credential-aware storefront requests accept either storefront or API sessions', function ($requestClass) {
+ request()->session()->forget('api_credential');
+ session(['storefront_key' => 'network_public_key']);
+ expect((bool) (new $requestClass())->authorize())->toBeTrue();
+
+ session(['storefront_key' => null]);
+ request()->session()->put('api_credential', 'credential_1');
+ expect((bool) (new $requestClass())->authorize())->toBeTrue();
+
+ request()->session()->forget('api_credential');
+ expect((bool) (new $requestClass())->authorize())->toBeFalse();
+})->with([
+ CreateCustomerRequest::class,
+ CreateProductRequest::class,
+ CreateReviewRequest::class,
+ VerifyCreateCustomerRequest::class,
+]);
+
+test('staff network requests require an authenticated user session', function ($requestClass) {
+ session(['user' => 'user_1']);
+ expect((new $requestClass())->authorize())->toBeTrue();
+
+ session(['user' => null]);
+ expect((new $requestClass())->authorize())->toBeFalse();
+})->with([
+ [AddStoreToNetworkCategory::class],
+ [NetworkActionRequest::class],
+]);
+
+test('checkout initialization rules require core identities and delivery quote conditionally', function () {
+ $deliveryRequest = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => false]);
+ $pickupRequest = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => true]);
+
+ $deliveryRules = $deliveryRequest->rules();
+ $pickupRules = $pickupRequest->rules();
+
+ expect($deliveryRules)->toHaveKeys(['gateway', 'customer', 'cart', 'serviceQuote', 'cash', 'pickup'])
+ ->and($deliveryRules['gateway'][1])->toBeInstanceOf(GatewayExists::class)
+ ->and($deliveryRules['customer'][1])->toBeInstanceOf(CustomerExists::class)
+ ->and($deliveryRules['serviceQuote'][0])->toBeInstanceOf(RequiredIf::class)
+ ->and((string) $deliveryRules['serviceQuote'][0])->toBe('required')
+ ->and((string) $pickupRules['serviceQuote'][0])->toBe('');
+});
+
+test('service quote request varies origin validation by storefront key type', function () {
+ session(['storefront_key' => 'store_123']);
+ $storeRules = (new GetServiceQuoteFromCart())->rules();
+
+ session(['storefront_key' => 'network_123']);
+ $networkRules = (new GetServiceQuoteFromCart())->rules();
+
+ expect($storeRules['origin'][0])->toBe('required')
+ ->and($storeRules['origin'][1])->toBeInstanceOf(IsValidLocation::class)
+ ->and($storeRules['destination'][1])->toBeInstanceOf(IsValidLocation::class)
+ ->and($storeRules['cart'])->toBe('required')
+ ->and($networkRules['origin'])->toBe([]);
+});
+
+test('product request publishes complete create and update validation contracts', function () {
+ $createRules = CreateProductRequest::create('/products', 'POST')->rules();
+ $updateRules = UpdateProductRequest::create('/products/product_1', 'PATCH')->rules();
+
+ expect($createRules)->toHaveKeys([
+ 'name',
+ 'description',
+ 'tags',
+ 'meta',
+ 'sku',
+ 'price',
+ 'sale_price',
+ 'currency',
+ 'addons',
+ 'variants',
+ 'is_service',
+ 'is_bookable',
+ 'is_available',
+ 'is_on_sale',
+ 'is_recommended',
+ 'can_pickup',
+ 'youtube_urls',
+ 'status',
+ 'category',
+ 'addon_categories',
+ ])->and($createRules['price'][0])->toBeInstanceOf(RequiredIf::class)
+ ->and((string) $createRules['price'][0])->toBe('required')
+ ->and((string) $updateRules['price'][0])->toBe('')
+ ->and($createRules['status'])->toContain('in:draft,active,archived')
+ ->and($createRules['currency'])->toContain('size:3');
+});
+
+test('customer review verification and capture request rules preserve API contracts', function () {
+ $customerRules = (new CreateCustomerRequest())->rules();
+ $reviewRules = (new CreateReviewRequest())->rules();
+ $verificationRules = (new VerifyCreateCustomerRequest())->rules();
+ $captureRules = (new CaptureOrderRequest())->rules();
+ $setupRules = (new CreateStripeSetupIntentRequest())->rules();
+ $storefrontRules = (new StorefrontCustomerRequest())->rules();
+
+ expect($customerRules)->toHaveKeys(['code', 'name', 'email', 'phone'])
+ ->and($customerRules['code'])->toBe('required|exists:verification_codes,code')
+ ->and($reviewRules)->toBe([
+ 'rating' => 'required|numeric',
+ 'content' => 'required',
+ 'files' => 'sometimes|array',
+ 'rejected' => 'sometimes|boolean',
+ ])->and($verificationRules)->toBe([
+ 'mode' => 'required|in:email,sms',
+ 'identity' => 'required',
+ ])->and($captureRules['token'])->toBe(['required', 'exists:storefront.checkouts,token'])
+ ->and($setupRules['customer'][1])->toBeInstanceOf(CustomerExists::class)
+ ->and($storefrontRules['customer'][1])->toBeInstanceOf(CustomerExists::class);
+});
+
+test('customer uniqueness rules scope active email and phone identities to the company', function () {
+ session(['company' => 'company_uuid']);
+ $rules = (new CreateCustomerRequest())->rules();
+ $emailBuilder = (new Fleetbase\FleetOps\Models\Contact())->newQuery();
+ $phoneBuilder = (new Fleetbase\FleetOps\Models\Contact())->newQuery();
+ $rules['email'][2]->queryCallbacks()[0]($emailBuilder);
+ $rules['phone'][1]->queryCallbacks()[0]($phoneBuilder);
+
+ expect($emailBuilder->toSql())->toContain('"company_uuid" = ?')
+ ->and($emailBuilder->toSql())->toContain('"deleted_at" is null')
+ ->and($emailBuilder->getBindings())->toContain('company_uuid')
+ ->and($phoneBuilder->toSql())->toContain('"company_uuid" = ?')
+ ->and($phoneBuilder->toSql())->toContain('"deleted_at" is null')
+ ->and($phoneBuilder->getBindings())->toContain('company_uuid');
+});
+
+test('network route requests merge route identity into validation input', function ($requestClass, $expectedRules) {
+ $request = $requestClass::create('/networks/network_uuid', 'POST', [
+ 'category' => 'category_uuid',
+ 'store' => 'store_uuid',
+ ]);
+ $request->setRouteResolver(fn () => new class {
+ public function parameter(string $key): ?string
+ {
+ return $key === 'id' ? 'network_uuid' : null;
+ }
+ });
+
+ expect($request->all())->toMatchArray([
+ 'id' => 'network_uuid',
+ 'category' => 'category_uuid',
+ 'store' => 'store_uuid',
+ ])->and($request->rules())->toBe($expectedRules);
+})->with([
+ 'network action' => [
+ NetworkActionRequest::class,
+ [
+ 'id' => ['required', 'exists:storefront.networks,uuid'],
+ ],
+ ],
+ 'add store category' => [
+ AddStoreToNetworkCategory::class,
+ [
+ 'id' => ['required', 'exists:storefront.networks,uuid'],
+ 'category' => ['required', 'exists:categories,uuid'],
+ 'store' => ['required', 'exists:storefront.stores,uuid', 'exists:storefront.network_stores,store_uuid'],
+ ],
+ ],
+]);
diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php
new file mode 100644
index 00000000..8d87e8cc
--- /dev/null
+++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php
@@ -0,0 +1,246 @@
+setRouteResolver(fn () => new class($uri) {
+ public array $action = [];
+
+ public function __construct(private string $routeUri)
+ {
+ }
+
+ public function uri(): string
+ {
+ return $this->routeUri;
+ }
+ });
+
+ return $request;
+}
+
+function storefrontProductResourceFixture(): ProductModel
+{
+ $image = (object) [
+ 'content_type' => 'image/png',
+ 'url' => 'https://cdn.test/product.png',
+ ];
+ $video = (object) [
+ 'content_type' => 'video/mp4',
+ 'url' => 'https://cdn.test/product.mp4',
+ ];
+ $addon = (object) [
+ 'id' => 30,
+ 'uuid' => 'addon_uuid',
+ 'public_id' => 'addon_123',
+ 'name' => 'Gift wrap',
+ 'description' => 'Premium wrapping',
+ 'price' => 250,
+ 'sale_price' => 200,
+ 'is_on_sale' => true,
+ 'slug' => 'gift-wrap',
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ];
+ $addonCategory = (object) [
+ 'id' => 20,
+ 'uuid' => 'product_addon_category_uuid',
+ 'product_uuid' => 'product_uuid',
+ 'category_uuid' => 'addon_category_uuid',
+ 'name' => 'Packaging',
+ 'excluded_addons' => [],
+ 'category' => (object) [
+ 'public_id' => 'addon_category_123',
+ 'description'=> 'Packaging choices',
+ 'addons' => collect([$addon]),
+ ],
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ];
+ $variantOption = (object) [
+ 'id' => 50,
+ 'uuid' => 'variant_option_uuid',
+ 'public_id' => 'variant_option_123',
+ 'name' => 'Large',
+ 'description' => 'Large size',
+ 'additional_cost' => 150,
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ];
+ $variant = (object) [
+ 'id' => 40,
+ 'uuid' => 'variant_uuid',
+ 'public_id' => 'variant_123',
+ 'name' => 'Size',
+ 'description' => 'Choose a size',
+ 'is_multiselect' => false,
+ 'is_required' => true,
+ 'slug' => 'size',
+ 'options' => collect([$variantOption]),
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ];
+
+ $product = new ProductModel();
+ $product->forceFill([
+ 'id' => 10,
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_123',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'category_uuid',
+ 'created_by_uuid' => 'user_uuid',
+ 'primary_image_uuid' => 'image_uuid',
+ 'name' => 'Cold Brew Kit',
+ 'description' => 'Brew coffee at home',
+ 'sku' => 'CBK-1',
+ 'price' => 5000,
+ 'sale_price' => 4500,
+ 'currency' => 'USD',
+ 'is_on_sale' => true,
+ 'is_recommended' => true,
+ 'is_service' => false,
+ 'is_bookable' => false,
+ 'is_available' => true,
+ 'tags' => ['coffee'],
+ 'status' => 'active',
+ 'meta' => ['origin' => 'SG'],
+ 'slug' => 'cold-brew-kit',
+ 'translations' => ['mn' => ['name' => 'Cold Brew']],
+ 'youtube_urls' => ['https://youtube.test/demo'],
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ]);
+ $product->setRelation('category', (object) ['public_id' => 'category_123']);
+ $product->setRelation('primaryImage', $image);
+ $product->setRelation('addonCategories', collect([$addonCategory]));
+ $product->setRelation('variants', collect([$variant]));
+ $product->setRelation('files', collect([$image, $video]));
+ $product->setRelation('hours', collect([
+ [
+ 'id' => 60,
+ 'uuid' => 'hour_uuid',
+ 'day_of_week' => 1,
+ 'start' => '09:00',
+ 'end' => '17:00',
+ ],
+ ]));
+
+ return $product;
+}
+
+test('public product resource exposes purchasable shape and filters media types', function () {
+ $request = setStorefrontResourceRoute('v1/storefront/products/product_123');
+ $data = (new ProductResource(storefrontProductResourceFixture()))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'product_123',
+ 'name' => 'Cold Brew Kit',
+ 'price' => 5000,
+ 'sale_price' => 4500,
+ 'currency' => 'USD',
+ 'is_on_sale' => true,
+ 'is_available' => true,
+ 'youtube_urls' => ['https://youtube.test/demo'],
+ ])->and($data['images']->all())->toBe(['https://cdn.test/product.png'])
+ ->and($data['videos']->all())->toBe(['https://cdn.test/product.mp4'])
+ ->and($data)->not->toHaveKeys([
+ 'uuid',
+ 'company_uuid',
+ 'store_uuid',
+ 'files',
+ 'type',
+ ])->and($data['addon_categories'][0])->toMatchArray([
+ 'id' => 'addon_category_123',
+ 'name' => 'Packaging',
+ 'description' => 'Packaging choices',
+ ])->and($data['addon_categories'][0]['addons'][0])->toMatchArray([
+ 'id' => 'addon_123',
+ 'name' => 'Gift wrap',
+ ])->and($data['variants'][0])->toMatchArray([
+ 'id' => 'variant_123',
+ 'name' => 'Size',
+ ])->and($data['variants'][0]['options'][0])->toMatchArray([
+ 'id' => 'variant_option_123',
+ 'additional_cost' => 150,
+ ])->and($data['hours'][0])->toMatchArray([
+ 'day' => 1,
+ 'start' => '09:00',
+ 'end' => '17:00',
+ ]);
+});
+
+test('internal product resource includes database identities and raw files', function () {
+ $request = setStorefrontResourceRoute('int/v1/storefront/products/product_123');
+ $data = (new ProductResource(storefrontProductResourceFixture()))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 10,
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_123',
+ 'company_uuid' => 'company_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'type' => 'product',
+ ])->and($data)->toHaveKey('files')
+ ->and($data)->not->toHaveKeys(['images', 'videos'])
+ ->and($data['addon_categories'][0])->toMatchArray([
+ 'id' => 20,
+ 'uuid' => 'product_addon_category_uuid',
+ 'product_uuid' => 'product_uuid',
+ 'category_uuid' => 'addon_category_uuid',
+ 'public_id' => 'addon_category_123',
+ ])->and($data['addon_categories'][0])->not->toHaveKey('addons')
+ ->and($data['variants'][0])->toMatchArray([
+ 'id' => 40,
+ 'uuid' => 'variant_uuid',
+ 'public_id' => 'variant_123',
+ ])->and($data['variants'][0]['options'][0])->toMatchArray([
+ 'id' => 50,
+ 'uuid' => 'variant_option_uuid',
+ 'public_id' => 'variant_option_123',
+ ])->and($data['hours'][0])->toMatchArray([
+ 'id' => 60,
+ 'uuid' => 'hour_uuid',
+ ]);
+});
+
+test('product mapping helpers accept arrays collections exclusions and empty inputs', function () {
+ $resource = new ProductResource((object) []);
+ $fixture = storefrontProductResourceFixture();
+
+ setStorefrontResourceRoute('v1/storefront/products');
+
+ expect($resource->mapHours([]))->toBe([])
+ ->and($resource->mapHours([[
+ 'day_of_week' => 2,
+ 'start' => '10:00',
+ 'end' => '18:00',
+ ]]))->toBe([[
+ 'day_of_week' => 2,
+ 'start' => '10:00',
+ 'end' => '18:00',
+ 'day' => 2,
+ ]])->and($resource->mapFiles([]))->toBeInstanceOf(Illuminate\Support\Collection::class)
+ ->and($resource->mapFiles($fixture->files, 'audio'))->toBeEmpty()
+ ->and($resource->mapAddonCategories([]))->toBeEmpty()
+ ->and($resource->mapVariants([]))->toBeEmpty();
+
+ $addons = $fixture->addonCategories[0]->category->addons;
+
+ expect($resource->mapProductAddons($addons, ['addon_uuid']))->toBeEmpty()
+ ->and($resource->mapProductAddons($addons, 'not-an-array'))->toHaveCount(1);
+
+ setStorefrontResourceRoute('int/v1/storefront/products');
+ $internalAddon = $resource->mapProductAddons($addons)->first();
+
+ expect($internalAddon)->toMatchArray([
+ 'id' => 30,
+ 'uuid' => 'addon_uuid',
+ 'public_id' => 'addon_123',
+ 'name' => 'Gift wrap',
+ ]);
+});
diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php
new file mode 100644
index 00000000..f81cfcd8
--- /dev/null
+++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php
@@ -0,0 +1,469 @@
+values;
+ }
+}
+
+test('cart resource exposes public totals and hides database ownership fields', function () {
+ $cart = storefrontResourceModel([
+ 'id' => 7,
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'checkout_uuid' => 'checkout_uuid',
+ 'customer_id' => 'customer_public',
+ 'currency' => 'USD',
+ 'subtotal' => 4200,
+ 'total_items' => 3,
+ 'total_unique_items' => 2,
+ 'items' => [],
+ 'events' => [['event' => 'created']],
+ 'discount_code' => 'SAVE10',
+ 'expires_at' => '2026-07-27 12:00:00',
+ 'created_at' => '2026-07-26 12:00:00',
+ 'updated_at' => '2026-07-26 12:30:00',
+ ]);
+
+ $public = (new CartResource($cart))->resolve(setSimpleStorefrontResourceRoute('v1/storefront/cart'));
+
+ expect($public)->toMatchArray([
+ 'id' => 'cart_public',
+ 'customer_id' => 'customer_public',
+ 'currency' => 'USD',
+ 'subtotal' => 4200,
+ 'total_items' => 3,
+ 'total_unique_items' => 2,
+ 'items' => [],
+ 'discount_code' => 'SAVE10',
+ ])->and($public)->not->toHaveKeys(['uuid', 'company_uuid', 'user_uuid', 'checkout_uuid']);
+
+ $internal = (new CartResource($cart))->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/cart'));
+
+ expect($internal)->toMatchArray([
+ 'id' => 7,
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'checkout_uuid' => 'checkout_uuid',
+ ]);
+});
+
+test('cart resource enriches known products while preserving unknown line items', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->dropIfExists('files');
+ $schema->create('products', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('subject_uuid')->nullable();
+ $table->string('url')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'primary_image_uuid' => null,
+ 'name' => 'Cold Brew',
+ 'description' => 'Ready to drink',
+ ]);
+
+ $cart = storefrontResourceModel([
+ 'public_id' => 'cart_public',
+ 'items' => [
+ ['product_id' => 'product_public', 'quantity' => 2],
+ ['product_id' => 'product_missing', 'quantity' => 1],
+ ],
+ ]);
+ $items = (new CartResource($cart))->getCartItems();
+
+ expect($items[0])->toMatchArray([
+ 'product_id' => 'product_public',
+ 'quantity' => 2,
+ 'name' => 'Cold Brew',
+ 'description' => 'Ready to drink',
+ 'product_image_url' => 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png',
+ ])->and($items[1])->toBe([
+ 'product_id' => 'product_missing',
+ 'quantity' => 1,
+ ]);
+});
+
+test('customer resource exposes public identity address and loaded address collection', function () {
+ $place = storefrontResourceModel([
+ 'public_id' => 'place_public',
+ 'address' => '1 Market Street',
+ ]);
+ $customer = storefrontResourceModel([
+ 'id' => 8,
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'user_uuid' => 'user_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'place_uuid' => 'place_uuid',
+ 'photo_uuid' => 'photo_uuid',
+ 'internal_id' => 'C-100',
+ 'name' => 'Ada Buyer',
+ 'title' => 'Ms',
+ 'photo_url' => 'https://cdn.example.test/ada.png',
+ 'email' => 'ada@example.test',
+ 'phone' => '+15550100',
+ 'token' => 'customer-token',
+ 'meta' => ['segment' => 'vip'],
+ 'slug' => 'ada-buyer',
+ 'created_at' => '2026-07-26',
+ 'updated_at' => '2026-07-27',
+ ], [
+ 'place' => $place,
+ 'places' => collect([$place]),
+ ]);
+
+ $request = setSimpleStorefrontResourceRoute('v1/storefront/customers/contact_public');
+ $data = (new CustomerResource($customer))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'customer_public',
+ 'address_id' => 'place_public',
+ 'internal_id'=> 'C-100',
+ 'name' => 'Ada Buyer',
+ 'address' => '1 Market Street',
+ 'token' => 'customer-token',
+ 'orders' => 0,
+ 'meta' => ['segment' => 'vip'],
+ ])->and($data['addresses'])->toHaveCount(1)
+ ->and($data)->not->toHaveKeys(['uuid', 'user_uuid', 'company_uuid', 'place_uuid']);
+});
+
+test('customer resource scopes order counts to the requested store or network', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('customer_uuid');
+ $table->text('meta');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => 'order_store',
+ 'customer_uuid' => 'contact_uuid',
+ 'meta' => json_encode(['storefront_id' => 'store_public']),
+ ],
+ [
+ 'uuid' => 'order_network',
+ 'customer_uuid' => 'contact_uuid',
+ 'meta' => json_encode(['storefront_network_id' => 'network_public']),
+ ],
+ ]);
+
+ $customer = storefrontResourceModel([
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ ], [
+ 'place' => null,
+ 'places' => collect(),
+ ]);
+
+ $storeRequest = setSimpleStorefrontResourceRoute('v1/storefront/customers/contact_public');
+ $storeRequest->query->set('storefront', 'store_public');
+ $networkRequest = Request::create('/v1/storefront/customers/contact_public', 'GET', [
+ 'network' => 'network_public',
+ ]);
+ $networkRequest->setLaravelSession(request()->session());
+ $networkRequest->setRouteResolver(fn () => new class {
+ public array $action = [];
+
+ public function uri(): string
+ {
+ return 'v1/storefront/customers/contact_public';
+ }
+ });
+
+ expect((new CustomerResource($customer))->resolve($storeRequest)['orders'])->toBe(1)
+ ->and((new CustomerResource($customer))->resolve($networkRequest)['orders'])->toBe(1);
+});
+
+test('catalog product resource preserves catalog purchasable shape', function () {
+ $request = setStorefrontResourceRoute('v1/storefront/catalogs/catalog_public/products');
+ $data = (new CatalogProductResource(storefrontProductResourceFixture()))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'product_123',
+ 'name' => 'Cold Brew Kit',
+ 'price' => 5000,
+ 'sale_price' => 4500,
+ 'currency' => 'USD',
+ 'is_available' => true,
+ 'status' => 'active',
+ ])->and($data['images']->all())->toBe(['https://cdn.test/product.png'])
+ ->and($data['videos']->all())->toBe(['https://cdn.test/product.mp4'])
+ ->and($data)->not->toHaveKeys(['uuid', 'company_uuid', 'files', 'type']);
+});
+
+test('order resources normalize and restrict nested storefront metadata shapes', function () {
+ $detailOrder = new Fleetbase\FleetOps\Models\Order();
+ $detailOrder->forceFill([
+ 'meta' => (object) [
+ 'storefront' => (object) [
+ 'id' => 'store_public',
+ 'name' => 'Central Store',
+ 'logo_url' => 'https://cdn.test/store.png',
+ 'is_store' => true,
+ 'credential' => 'hidden',
+ ],
+ 'storefront_id' => 'store_public',
+ 'checkout_id' => 'checkout_public',
+ 'secret' => 'hidden',
+ ],
+ ]);
+ $detailResource = new OrderResource($detailOrder);
+ $detailMeta = (new ReflectionMethod($detailResource, 'storefrontOrderMeta'))->invoke($detailResource);
+
+ $indexOrder = new Fleetbase\FleetOps\Models\Order();
+ $indexOrder->forceFill([
+ 'meta' => new StorefrontMetaArrayable([
+ 'storefront' => [
+ 'public_id' => 'network_public',
+ 'name' => 'Delivery Network',
+ 'is_network'=> true,
+ 'private' => 'hidden',
+ ],
+ 'storefront_network_id' => 'network_public',
+ 'currency' => 'MNT',
+ 'checkout_id' => 'excluded_from_index',
+ ]),
+ ]);
+ $indexResource = new IndexOrderResource($indexOrder);
+ $indexMeta = (new ReflectionMethod($indexResource, 'storefrontOrderMeta'))->invoke($indexResource);
+ $detailNormalized = (new ReflectionMethod($detailResource, 'normalizeMeta'))->invoke(
+ $detailResource,
+ new StorefrontMetaArrayable(['currency' => 'USD'])
+ );
+ $indexNormalized = (new ReflectionMethod($indexResource, 'normalizeMeta'))->invoke(
+ $indexResource,
+ (object) ['currency' => 'MNT']
+ );
+
+ expect($detailMeta)->toBe([
+ 'storefront' => [
+ 'id' => 'store_public',
+ 'name' => 'Central Store',
+ 'logo_url' => 'https://cdn.test/store.png',
+ 'is_store' => true,
+ ],
+ 'storefront_id' => 'store_public',
+ 'checkout_id' => 'checkout_public',
+ ])->and($indexMeta)->toBe([
+ 'storefront' => [
+ 'public_id' => 'network_public',
+ 'name' => 'Delivery Network',
+ 'is_network'=> true,
+ ],
+ 'storefront_network_id' => 'network_public',
+ 'currency' => 'MNT',
+ ])->and($detailNormalized)->toBe(['currency' => 'USD'])
+ ->and($indexNormalized)->toBe(['currency' => 'MNT']);
+});
+
+test('food truck resource returns safe empty logistics relations and offline state', function () {
+ $truck = storefrontResourceModel([
+ 'id' => 9,
+ 'uuid' => 'truck_uuid',
+ 'public_id' => 'food_truck_public',
+ 'company_uuid' => 'company_uuid',
+ 'created_by_uuid' => 'user_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'service_area_uuid' => null,
+ 'zone_uuid' => null,
+ 'vehicle_uuid' => null,
+ 'status' => 'inactive',
+ 'created_at' => '2026-07-26',
+ 'updated_at' => '2026-07-27',
+ ], [
+ 'vehicle' => null,
+ 'serviceArea'=> null,
+ 'zone' => null,
+ 'catalogs' => collect(),
+ ]);
+
+ $data = (new FoodTruckResource($truck))
+ ->resolve(setSimpleStorefrontResourceRoute('v1/storefront/food-trucks'));
+
+ expect($data)->toMatchArray([
+ 'id' => 'food_truck_public',
+ 'vehicle' => null,
+ 'service_area' => null,
+ 'zone' => null,
+ 'location' => null,
+ 'online' => false,
+ 'status' => 'inactive',
+ ])->and($data['catalogs'])->toBeEmpty()
+ ->and($data)->not->toHaveKeys(['uuid', 'company_uuid', 'vehicle_uuid']);
+});
+
+test('review customer resource reports aggregate review and upload counts', function () {
+ $customer = new class extends Model {
+ protected $guarded = [];
+
+ public function reviews(): object
+ {
+ return new class {
+ public function count(): int
+ {
+ return 4;
+ }
+ };
+ }
+
+ public function reviewUploads(): object
+ {
+ return new class {
+ public function count(): int
+ {
+ return 6;
+ }
+ };
+ }
+ };
+ $customer->forceFill([
+ 'id' => 10,
+ 'uuid' => 'contact_uuid',
+ 'public_id' => 'contact_public',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'phone' => '+15550100',
+ 'photo_url' => 'https://cdn.example.test/ada.png',
+ 'slug' => 'ada-buyer',
+ 'created_at' => '2026-07-26',
+ 'updated_at' => '2026-07-27',
+ ]);
+
+ $data = (new ReviewCustomerResource($customer))
+ ->resolve(setSimpleStorefrontResourceRoute('v1/storefront/review-customers'));
+
+ expect($data)->toMatchArray([
+ 'id' => 'customer_public',
+ 'name' => 'Ada Buyer',
+ 'reviews_count' => 4,
+ 'uploads_count' => 6,
+ ])->and($data)->not->toHaveKeys(['uuid', 'public_id']);
+});
+
+test('store hour and location resources preserve customer-facing scheduling shapes', function () {
+ $hour = storefrontResourceModel([
+ 'id' => 11,
+ 'uuid' => 'hour_uuid',
+ 'public_id' => 'hour_public',
+ 'day_of_week' => 1,
+ 'start' => '09:00',
+ 'end' => '17:00',
+ 'created_at' => '2026-07-26',
+ 'updated_at' => '2026-07-27',
+ ]);
+ $store = storefrontResourceModel(['public_id' => 'store_public']);
+ $location = storefrontResourceModel([
+ 'id' => 12,
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'location_public',
+ 'name' => 'Downtown',
+ 'created_at' => '2026-07-26',
+ 'updated_at' => '2026-07-27',
+ ], [
+ 'store' => $store,
+ 'place' => null,
+ 'hours' => collect([$hour]),
+ ]);
+
+ $request = setSimpleStorefrontResourceRoute('v1/storefront/store-locations');
+ $hourData = (new StoreHourResource($hour))->resolve($request);
+ $locationData = (new StoreLocationResource($location))->resolve($request);
+
+ expect($hourData)->toBe([
+ 'id' => 'hour_public',
+ 'day' => 1,
+ 'start' => '09:00',
+ 'end' => '17:00',
+ ])->and($locationData)->toMatchArray([
+ 'id' => 'location_public',
+ 'store' => 'store_public',
+ 'name' => 'Downtown',
+ 'place' => null,
+ ])->and($locationData['hours'])->toHaveCount(1)
+ ->and($locationData)->not->toHaveKeys(['uuid', 'public_id', 'store_data']);
+});
+
+test('store resource exposes public branding and filters internal option state', function () {
+ $store = new class extends Model {
+ protected $guarded = [];
+
+ public function getNetworkCategoryUsingId(): mixed
+ {
+ return null;
+ }
+ };
+ $store->forceFill([
+ 'id' => 13,
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'key' => 'store_secret',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Corner Store',
+ 'description' => 'Neighborhood groceries',
+ 'currency' => 'USD',
+ 'options' => ['pickup' => true, 'alerted_for_new_order' => true],
+ 'logo_url' => 'https://cdn.example.test/logo.png',
+ 'backdrop_url' => 'https://cdn.example.test/backdrop.png',
+ 'rating' => 4.8,
+ 'online' => true,
+ 'alertable' => ['email'],
+ 'slug' => 'corner-store',
+ ]);
+ foreach (['networks', 'locations', 'media'] as $relation) {
+ $store->setRelation($relation, collect());
+ }
+
+ $data = (new StoreResource($store))
+ ->resolve(setSimpleStorefrontResourceRoute('v1/storefront/stores/store_public'));
+
+ expect($data)->toMatchArray([
+ 'id' => 'store_public',
+ 'name' => 'Corner Store',
+ 'description' => 'Neighborhood groceries',
+ 'currency' => 'USD',
+ 'country' => 'AS',
+ 'options' => ['pickup' => true],
+ 'is_network' => false,
+ 'is_store' => true,
+ ])->and($data)->not->toHaveKeys(['uuid', 'key', 'company_uuid']);
+});
diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php
new file mode 100644
index 00000000..3d55ff3a
--- /dev/null
+++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php
@@ -0,0 +1,366 @@
+forceFill($attributes);
+
+ foreach ($relations as $name => $relation) {
+ $model->setRelation($name, $relation);
+ }
+
+ return $model;
+}
+
+function setSimpleStorefrontResourceRoute(string $uri): Illuminate\Http\Request
+{
+ $request = request();
+ $request->setRouteResolver(fn () => new class($uri) {
+ public array $action = [];
+
+ public function __construct(private string $routeUri)
+ {
+ }
+
+ public function uri(): string
+ {
+ return $this->routeUri;
+ }
+ });
+
+ return $request;
+}
+
+test('gateway resource hides configuration publicly and exposes it internally', function () {
+ $gateway = storefrontResourceModel([
+ 'id' => 1,
+ 'uuid' => 'gateway_uuid',
+ 'public_id' => 'gateway_123',
+ 'owner_uuid' => 'store_uuid',
+ 'name' => 'Stripe',
+ 'description' => 'Card payments',
+ 'logo_url' => 'https://cdn.test/stripe.png',
+ 'code' => 'stripe',
+ 'type' => 'payment',
+ 'sandbox' => true,
+ 'return_url' => 'https://store.test/return',
+ 'callback_url' => 'https://store.test/callback',
+ 'meta' => ['provider' => 'stripe'],
+ 'config' => ['secret_key' => 'must-not-leak'],
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ]);
+
+ $publicRequest = setSimpleStorefrontResourceRoute('v1/storefront/gateways');
+ $public = (new GatewayResource($gateway))->resolve($publicRequest);
+
+ expect($public)->toMatchArray([
+ 'id' => 'gateway_123',
+ 'name' => 'Stripe',
+ 'code' => 'stripe',
+ 'sandbox' => true,
+ ])->and($public)->not->toHaveKeys(['uuid', 'owner_uuid', 'config']);
+
+ $internalRequest = setSimpleStorefrontResourceRoute('int/v1/storefront/gateways');
+ $internal = (new GatewayResource($gateway))->resolve($internalRequest);
+
+ expect($internal)->toMatchArray([
+ 'id' => 1,
+ 'uuid' => 'gateway_uuid',
+ 'public_id' => 'gateway_123',
+ 'owner_uuid' => 'store_uuid',
+ 'config' => ['secret_key' => 'must-not-leak'],
+ ]);
+});
+
+test('notification channel resource preserves delivery scheme and internal ownership', function () {
+ $channel = storefrontResourceModel([
+ 'id' => 2,
+ 'uuid' => 'channel_uuid',
+ 'public_id' => 'channel_123',
+ 'company_uuid' => 'company_uuid',
+ 'created_by_uuid' => 'user_uuid',
+ 'certificate_uuid' => 'certificate_uuid',
+ 'owner_uuid' => 'store_uuid',
+ 'owner_type' => 'store',
+ 'name' => 'Mobile push',
+ 'scheme' => 'fcm',
+ 'options' => ['topic' => 'orders'],
+ 'config' => ['project' => 'storefront'],
+ 'app_key' => 'app-key',
+ 'is_apn_gateway' => false,
+ 'is_fcm_gateway' => true,
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ]);
+
+ $public = (new NotificationChannelResource($channel))
+ ->resolve(setSimpleStorefrontResourceRoute('v1/storefront/notification-channels'));
+
+ expect($public)->toMatchArray([
+ 'id' => 'channel_123',
+ 'name' => 'Mobile push',
+ 'scheme' => 'fcm',
+ 'is_fcm_gateway' => true,
+ ])->and($public)->not->toHaveKeys(['uuid', 'company_uuid', 'owner_uuid']);
+
+ $internal = (new NotificationChannelResource($channel))
+ ->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/notification-channels'));
+
+ expect($internal)->toMatchArray([
+ 'id' => 2,
+ 'uuid' => 'channel_uuid',
+ 'public_id' => 'channel_123',
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'store_uuid',
+ ]);
+});
+
+test('media and review resources map customer-facing file shapes', function () {
+ $photo = storefrontResourceModel([
+ 'id' => 3,
+ 'uuid' => 'file_uuid',
+ 'public_id' => 'file_123',
+ 'original_filename' => 'receipt.jpg',
+ 'content_type' => 'image/jpeg',
+ 'caption' => 'Delivered order',
+ 'url' => 'https://cdn.test/receipt.jpg',
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ]);
+
+ $publicMedia = (new MediaResource($photo))
+ ->resolve(setSimpleStorefrontResourceRoute('v1/storefront/media'));
+
+ expect($publicMedia)->toBe([
+ 'id' => 'file_123',
+ 'filename' => 'receipt.jpg',
+ 'type' => 'image/jpeg',
+ 'caption' => 'Delivered order',
+ 'url' => 'https://cdn.test/receipt.jpg',
+ ]);
+
+ $customer = storefrontResourceModel([
+ 'public_id' => 'contact_123',
+ 'name' => 'Ada Lovelace',
+ ]);
+ $review = storefrontResourceModel([
+ 'id' => 4,
+ 'uuid' => 'review_uuid',
+ 'public_id' => 'review_123',
+ 'rating' => 5,
+ 'content' => 'Excellent service',
+ 'slug' => 'excellent-service',
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ], [
+ 'subject' => storefrontResourceModel(['id' => 99]),
+ 'customer' => $customer,
+ 'photos' => collect([$photo]),
+ ]);
+
+ $reviewData = (new ReviewResource($review))->toArray(setSimpleStorefrontResourceRoute('v1/storefront/reviews'));
+
+ expect($reviewData)->toMatchArray([
+ 'id' => 'review_123',
+ 'subject_id' => 99,
+ 'rating' => 5,
+ 'content' => 'Excellent service',
+ ])->and($reviewData['photos'][0])->toBe([
+ 'id' => 'file_123',
+ 'filename' => 'receipt.jpg',
+ 'type' => 'image/jpeg',
+ 'caption' => 'Delivered order',
+ 'url' => 'https://cdn.test/receipt.jpg',
+ ]);
+});
+
+test('catalog and category resources expose nested commerce navigation', function () {
+ $product = storefrontResourceModel(['public_id' => 'product_123']);
+ $catalogCategory = storefrontResourceModel([
+ 'id' => 5,
+ 'uuid' => 'catalog_category_uuid',
+ 'public_id' => 'catalog_category_123',
+ 'company_uuid' => 'company_uuid',
+ 'parent_uuid' => null,
+ 'store_uuid' => 'store_uuid',
+ 'owner_uuid' => 'catalog_uuid',
+ 'name' => 'Coffee',
+ 'description' => 'Coffee products',
+ 'icon_url' => 'https://cdn.test/coffee.png',
+ 'tags' => ['drinks'],
+ 'meta' => ['featured' => true],
+ 'for' => 'catalog',
+ 'order' => 1,
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ], [
+ 'products' => collect([$product]),
+ ]);
+ $catalog = storefrontResourceModel([
+ 'id' => 6,
+ 'uuid' => 'catalog_uuid',
+ 'public_id' => 'catalog_123',
+ 'company_uuid' => 'company_uuid',
+ 'created_by_uuid' => 'user_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Main Menu',
+ 'description' => 'Available products',
+ 'status' => 'published',
+ 'created_at' => '2026-01-01',
+ 'updated_at' => '2026-01-02',
+ ], [
+ 'categories' => collect([$catalogCategory]),
+ ]);
+
+ $request = setSimpleStorefrontResourceRoute('v1/storefront/catalogs');
+ $data = (new CatalogResource($catalog))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'catalog_123',
+ 'name' => 'Main Menu',
+ 'description' => 'Available products',
+ 'status' => 'published',
+ ])->and($data)->not->toHaveKeys(['uuid', 'company_uuid', 'store_uuid'])
+ ->and($data['categories'])->toHaveCount(1);
+
+ $categoryData = (new CatalogCategoryResource($catalogCategory))->resolve($request);
+
+ expect($categoryData)->toMatchArray([
+ 'id' => 'catalog_category_123',
+ 'name' => 'Coffee',
+ 'description' => 'Coffee products',
+ 'tags' => ['drinks'],
+ 'meta' => ['featured' => true],
+ 'order' => 1,
+ ])->and($categoryData['products'])->toHaveCount(1);
+});
+
+test('category resource controls optional products and nested categories', function () {
+ $child = storefrontResourceModel([
+ 'public_id' => 'category_child',
+ 'name' => 'Child',
+ 'description' => 'Nested category',
+ 'tags' => [],
+ 'translations'=> [],
+ 'meta' => [],
+ 'order' => 2,
+ 'slug' => 'child',
+ ], [
+ 'parentCategory' => null,
+ 'products' => collect(),
+ 'subCategories' => collect(),
+ ]);
+ $category = storefrontResourceModel([
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_parent',
+ 'name' => 'Parent',
+ 'description' => 'Top category',
+ 'icon_url' => 'https://cdn.test/category.png',
+ 'tags' => ['featured'],
+ 'translations'=> [],
+ 'meta' => ['color' => 'blue'],
+ 'order' => 1,
+ 'slug' => 'parent',
+ ], [
+ 'parentCategory' => storefrontResourceModel(['public_id' => 'category_root']),
+ 'products' => collect([storefrontResourceModel(['public_id' => 'product_123'])]),
+ 'subCategories' => collect([$child]),
+ ]);
+
+ $request = setSimpleStorefrontResourceRoute('v1/storefront/categories');
+ $request->query->set('with', ['products', 'subcategories']);
+ $data = (new CategoryResource($category))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'category_parent',
+ 'name' => 'Parent',
+ 'description' => 'Top category',
+ 'tags' => ['featured'],
+ 'meta' => ['color' => 'blue'],
+ 'order' => 1,
+ 'parent' => 'category_root',
+ ])->and($data['products'])->toHaveCount(1)
+ ->and($data['subcategories'])->toHaveCount(1);
+});
+
+test('store options remove internal alert state while preserving storefront configuration', function () {
+ $resource = new StoreResource(storefrontResourceModel());
+
+ expect($resource->formatOptions(null))->toBe([])
+ ->and($resource->formatOptions('invalid'))->toBe([])
+ ->and($resource->formatOptions([
+ 'alerted_for_new_order' => true,
+ 'show_tax' => true,
+ 'theme' => 'dark',
+ ]))->toBe([
+ 'show_tax' => true,
+ 'theme' => 'dark',
+ ]);
+});
+
+test('network resource includes requested related collections and storefront flags', function () {
+ $network = storefrontResourceModel([
+ 'id' => 7,
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_123',
+ 'key' => 'network-key',
+ 'company_uuid' => 'company_uuid',
+ 'created_by_uuid' => 'user_uuid',
+ 'logo_uuid' => 'logo_uuid',
+ 'backdrop_uuid' => 'backdrop_uuid',
+ 'order_config_uuid' => 'order_config_uuid',
+ 'name' => 'Merchant Network',
+ 'description' => 'Shared marketplace',
+ 'translations' => [],
+ 'website' => 'https://network.test',
+ 'facebook' => null,
+ 'instagram' => null,
+ 'twitter' => null,
+ 'email' => 'network@example.test',
+ 'phone' => '+1 555 0100',
+ 'tags' => ['marketplace'],
+ 'currency' => 'USD',
+ 'options' => ['pickup' => true],
+ 'alertable' => true,
+ 'logo_url' => 'https://cdn.test/logo.png',
+ 'backdrop_url' => 'https://cdn.test/backdrop.png',
+ 'rating' => 4.8,
+ 'online' => true,
+ 'slug' => 'merchant-network',
+ ], [
+ 'stores' => collect(),
+ 'categories' => collect(),
+ 'gateways' => collect(),
+ 'notificationChannels' => collect(),
+ ]);
+
+ $request = setSimpleStorefrontResourceRoute('v1/storefront/networks');
+ $request->query->replace([
+ 'with_stores' => true,
+ 'with_categories' => true,
+ ]);
+ $data = (new NetworkResource($network))->resolve($request);
+
+ expect($data)->toMatchArray([
+ 'id' => 'network_123',
+ 'name' => 'Merchant Network',
+ 'currency' => 'USD',
+ 'is_network' => true,
+ 'is_store' => false,
+ ])->and($data)->toHaveKeys(['stores', 'categories'])
+ ->and($data)->not->toHaveKeys(['gateways', 'notification_channels', 'uuid']);
+});
diff --git a/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php b/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php
new file mode 100644
index 00000000..98bf7f3c
--- /dev/null
+++ b/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php
@@ -0,0 +1,574 @@
+notifications[] = $notification;
+ }
+}
+
+class DownloadProductImageUrlStub extends DownloadProductImageUrl
+{
+ public Fleetbase\Models\File $image;
+
+ protected function downloadProductImage(Product $product)
+ {
+ return $this->image;
+ }
+}
+
+class StorefrontListenerOrder extends Order
+{
+ public static ?StorefrontListenerCustomerSpy $customerSpy = null;
+
+ public function load($relations)
+ {
+ $this->setRelation('customer', static::$customerSpy);
+ $driver = new Fleetbase\FleetOps\Models\Driver();
+ $driver->forceFill(['name' => 'Ada Driver']);
+ $this->setRelation('driverAssigned', $driver);
+
+ return $this;
+ }
+}
+
+function builderOnFilter(object $filter, Builder $builder): void
+{
+ $property = new ReflectionProperty(Fleetbase\Http\Filter\Filter::class, 'builder');
+ $property->setValue($filter, $builder);
+}
+
+test('expansion targets point to their intended Fleetbase runtime classes', function () {
+ expect(ContactFilterExpansion::target())->toBe(ContactFilter::class)
+ ->and(EntityExpansion::target())->toBe(Entity::class)
+ ->and(OrderExpansion::target())->toBe(Order::class)
+ ->and(OrderFilterExpansion::target())->toBe(FleetOpsOrderFilter::class)
+ ->and(VendorFilterExpansion::target())->toBe(VendorFilter::class);
+});
+
+test('entity expansion maps storefront products into logistics entities', function () {
+ session(['company' => 'company_uuid']);
+ $product = new Product();
+ $product->forceFill([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'primary_image_uuid' => 'file_uuid',
+ 'name' => 'Cold Brew',
+ 'description' => 'Ready to drink',
+ 'currency' => 'USD',
+ 'sku' => 'CB-1',
+ 'price' => 500,
+ 'sale_price' => 450,
+ ]);
+ $product->setRelation('primaryImage', (object) ['url' => 'https://cdn.example.test/cold-brew.png']);
+ $product->setRelation('files', collect());
+
+ $factory = EntityExpansion::fromStorefrontProduct();
+ $entity = $factory($product, ['source' => 'catalog']);
+
+ expect($entity)->toBeInstanceOf(Entity::class)
+ ->and($entity->company_uuid)->toBe('company_uuid')
+ ->and($entity->internal_id)->toBe('product_public')
+ ->and($entity->name)->toBe('Cold Brew')
+ ->and($entity->price)->toBe(500)
+ ->and($entity->meta)->toMatchArray([
+ 'product_id' => 'product_public',
+ 'image_url' => 'https://cdn.example.test/cold-brew.png',
+ 'source' => 'catalog',
+ ]);
+});
+
+test('order expansion returns null when no storefront metadata is attached', function () {
+ $order = new Order();
+ $order->forceFill(['meta' => []]);
+
+ expect(OrderExpansion::getStorefrontAttribute()->call($order))->toBeNull();
+});
+
+test('order expansion resolves store and network storefront metadata', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('networks');
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ ]);
+ $storeOrder = new Order();
+ $storeOrder->forceFill(['meta' => ['storefront_id' => 'store_public']]);
+ $networkOrder = new Order();
+ $networkOrder->forceFill(['meta' => ['storefront_network_id' => 'network_public']]);
+
+ $store = OrderExpansion::getStorefrontAttribute()->call($storeOrder);
+ $network = OrderExpansion::getStorefrontAttribute()->call($networkOrder);
+
+ expect($store)->toBeInstanceOf(Fleetbase\Storefront\Models\Store::class)
+ ->and($store->uuid)->toBe('store_uuid')
+ ->and($network)->toBeInstanceOf(Network::class)
+ ->and($network->uuid)->toBe('network_uuid');
+});
+
+test('filter expansions apply storefront metadata and relationship scopes', function () {
+ $request = storefrontFilterRequest('v1/storefront/resources');
+
+ $orderFilter = new FleetOpsOrderFilter($request);
+ $orderBuilder = (new Order())->newQuery();
+ builderOnFilter($orderFilter, $orderBuilder);
+ OrderFilterExpansion::storefront()->call($orderFilter, 'store_public');
+
+ $contactFilter = new ContactFilter($request);
+ $contactBuilder = (new Fleetbase\Storefront\Models\Customer())->newQuery();
+ builderOnFilter($contactFilter, $contactBuilder);
+ ContactFilterExpansion::storefront()->call($contactFilter, 'store_public');
+
+ $vendorFilter = new VendorFilter($request);
+ $vendorBuilder = (new Fleetbase\FleetOps\Models\Vendor())->newQuery();
+ builderOnFilter($vendorFilter, $vendorBuilder);
+ VendorFilterExpansion::storefront()->call($vendorFilter, 'store_public');
+
+ expect($orderBuilder->toSql())->toContain('json_extract')
+ ->and($orderBuilder->getBindings())->toContain('store_public')
+ ->and($contactBuilder->toSql())->toContain('exists')
+ ->and($contactBuilder->getBindings())->toContain('store_public')
+ ->and($vendorBuilder->toSql())->toContain('exists')
+ ->and($vendorBuilder->getBindings())->toContain('store_public');
+});
+
+test('catalog and food truck observers synchronize request-backed empty assignments', function () {
+ $request = Request::create('/observer', 'POST', [
+ 'catalog' => ['categories' => []],
+ 'foodTruck' => ['catalogs' => []],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+
+ $catalog = new Catalog();
+ $catalog->setRelation('categories', collect());
+ (new CatalogObserver())->saved($catalog);
+
+ $foodTruck = new FoodTruck();
+ $foodTruck->setRelation('catalogs', collect());
+ (new FoodTruckObserver())->saved($foodTruck);
+
+ expect($catalog->categories)->toBeEmpty()
+ ->and($foodTruck->catalogs)->toBeEmpty();
+});
+
+test('food truck observer contains malformed catalog assignments without terminating the request', function () {
+ $request = Request::create('/observer', 'POST', [
+ 'foodTruck' => ['catalogs' => 'invalid-catalog-list'],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+ $foodTruck = new FoodTruck();
+ $foodTruck->forceFill(['uuid' => 'food_truck_uuid']);
+
+ expect((new FoodTruckObserver())->saved($foodTruck))->toBeNull();
+});
+
+test('catalog observer removes category product pivots when a catalog is deleted', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('categories');
+ $schema->dropIfExists('catalog_category_products');
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('owner_uuid');
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('catalog_category_products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('catalog_category_uuid');
+ $table->string('product_uuid');
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'owner_uuid' => 'catalog_uuid',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('catalog_category_products')->insert([
+ 'catalog_category_uuid' => 'category_uuid',
+ 'product_uuid' => 'product_uuid',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $catalog = new Catalog(['uuid' => 'catalog_uuid']);
+ (new CatalogObserver())->deleted($catalog);
+
+ expect($connection->table('categories')->where('uuid', 'category_uuid')->value('deleted_at'))->not->toBeNull()
+ ->and($connection->table('catalog_category_products')
+ ->where('catalog_category_uuid', 'category_uuid')
+ ->value('deleted_at'))->not->toBeNull();
+});
+
+test('product observer accepts empty nested assignments without side effects', function () {
+ $request = Request::create('/observer', 'POST', [
+ 'product' => [
+ 'addon_categories' => [],
+ 'variants' => [],
+ 'files' => [],
+ ],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+
+ (new ProductObserver())->saved($product);
+
+ expect($product->uuid)->toBe('product_uuid');
+});
+
+test('product observer associates submitted files and ignores stale file identifiers', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('files');
+ $schema->create('files', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('subject_uuid')->nullable();
+ $table->string('subject_type')->nullable();
+ $table->string('type')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('files')->insert([
+ 'uuid' => 'file_uuid',
+ ]);
+
+ $request = Request::create('/observer', 'POST', [
+ 'product' => [
+ 'addon_categories' => [],
+ 'variants' => [],
+ 'files' => [
+ ['uuid' => 'file_uuid'],
+ ['uuid' => 'stale_file_uuid'],
+ ],
+ ],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+
+ (new ProductObserver())->saved($product);
+
+ $file = Illuminate\Database\Capsule\Manager::connection('mysql')->table('files')->where('uuid', 'file_uuid')->first();
+ expect($file->subject_uuid)->toBe('product_uuid')
+ ->and($file->subject_type)->toContain('Product');
+});
+
+test('product observer logs and rethrows assignment failures', function () {
+ $request = Request::create('/observer', 'POST', [
+ 'product' => [
+ 'addon_categories' => [],
+ 'variants' => [],
+ 'files' => [],
+ ],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+
+ $product = new class extends Product {
+ public function setAddonCategories(array $addonCategories = []): Product
+ {
+ throw new RuntimeException('Unable to assign product categories');
+ }
+ };
+
+ expect(fn () => (new ProductObserver())->saved($product))
+ ->toThrow(RuntimeException::class, 'Unable to assign product categories');
+});
+
+test('network observer removes invalid alert groups and keeps normalized recipients', function () {
+ $request = Request::create('/observer', 'POST', [
+ 'network' => [
+ 'alertable' => [
+ 'orders' => ['user_a', 'user_b'],
+ 'invalid' => 'not-an-array',
+ 'payments' => ['user_c'],
+ ],
+ ],
+ ]);
+ $request->setLaravelSession(request()->session());
+ app()->instance('request', $request);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('request');
+
+ $network = new Network();
+ (new NetworkObserver())->updating($network);
+
+ expect($network->alertable)->toBe([
+ 'orders' => ['user_a', 'user_b'],
+ 'payments' => ['user_c'],
+ ]);
+});
+
+test('order observer preserves an explicitly selected order configuration', function () {
+ $order = new Order();
+ $order->forceFill(['order_config_uuid' => 'config_uuid']);
+
+ (new OrderObserver())->creating($order);
+
+ expect($order->order_config_uuid)->toBe('config_uuid');
+});
+
+test('order lifecycle listeners ignore non-storefront orders without notifying customers', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->text('meta')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('orders')->insert([
+ 'uuid' => 'order_uuid',
+ 'meta' => json_encode([]),
+ ]);
+
+ $cases = [
+ [Fleetbase\FleetOps\Events\OrderCompleted::class, new HandleOrderCompleted()],
+ [Fleetbase\FleetOps\Events\OrderDispatched::class, new HandleOrderDispatched()],
+ [Fleetbase\FleetOps\Events\OrderDriverAssigned::class, new HandleOrderDriverAssigned()],
+ [Fleetbase\FleetOps\Events\OrderStarted::class, new HandleOrderStarted()],
+ ];
+
+ foreach ($cases as [$eventClass, $listener]) {
+ $event = (new ReflectionClass($eventClass))->newInstanceWithoutConstructor();
+ $event->modelUuid = 'order_uuid';
+ $event->modelClassNamespace = Order::class;
+
+ expect($listener->handle($event))->toBeNull();
+ }
+});
+
+test('download image job serializes only the product id and source URL', function () {
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+ $job = new DownloadProductImageUrl($product, 'https://images.example.test/product.png');
+
+ expect($job->product)->toBe('product_uuid')
+ ->and($job->url)->toBe('https://images.example.test/product.png');
+});
+
+test('download image job ignores stale products and invalid image URLs without external calls', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'primary_image_uuid' => 'existing_image_uuid',
+ ]);
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+ $missing = new Product();
+ $missing->forceFill(['uuid' => 'missing_product_uuid']);
+
+ (new DownloadProductImageUrl($missing, 'not-a-url'))->handle();
+ (new DownloadProductImageUrl($product, 'not-a-url'))->handle();
+
+ expect($connection->table('products')->where('uuid', 'product_uuid')->value('primary_image_uuid'))
+ ->toBe('existing_image_uuid');
+});
+
+test('download image job assigns a successfully stored file as the product primary image', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'primary_image_uuid' => null,
+ ]);
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+ $image = new Fleetbase\Models\File();
+ $image->forceFill(['uuid' => 'file_uuid']);
+ $job = new DownloadProductImageUrlStub($product, 'https://images.example.test/product.png');
+ $job->image = $image;
+
+ $job->handle();
+
+ expect($connection->table('products')->where('uuid', 'product_uuid')->value('primary_image_uuid'))
+ ->toBe('file_uuid');
+});
+
+test('order lifecycle listeners notify storefront customers with transition-specific messages', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('orders');
+ $schema->dropIfExists('stores');
+ $schema->create('orders', function ($table) {
+ $table->string('uuid')->primary();
+ $table->text('meta')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('website')->nullable();
+ $table->string('facebook')->nullable();
+ $table->string('instagram')->nullable();
+ $table->string('twitter')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('timezone')->nullable();
+ $table->string('pod_method')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Test Store',
+ ]);
+ $connection->table('orders')->insert([
+ 'uuid' => 'storefront_order_uuid',
+ 'meta' => json_encode([
+ 'storefront_id' => 'store_public',
+ 'is_pickup' => true,
+ ]),
+ ]);
+ StorefrontListenerOrder::$customerSpy = new StorefrontListenerCustomerSpy();
+ $cases = [
+ [Fleetbase\FleetOps\Events\OrderCompleted::class, new HandleOrderCompleted(), Fleetbase\Storefront\Notifications\StorefrontOrderCompleted::class],
+ [Fleetbase\FleetOps\Events\OrderDispatched::class, new HandleOrderDispatched(), Fleetbase\Storefront\Notifications\StorefrontOrderReadyForPickup::class],
+ [Fleetbase\FleetOps\Events\OrderDriverAssigned::class, new HandleOrderDriverAssigned(), Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class],
+ [Fleetbase\FleetOps\Events\OrderStarted::class, new HandleOrderStarted(), Fleetbase\Storefront\Notifications\StorefrontOrderEnroute::class],
+ ];
+
+ foreach ($cases as [$eventClass, $listener, $notificationClass]) {
+ $event = (new ReflectionClass($eventClass))->newInstanceWithoutConstructor();
+ $event->modelUuid = 'storefront_order_uuid';
+ $event->modelClassNamespace = StorefrontListenerOrder::class;
+ $before = count(StorefrontListenerOrder::$customerSpy->notifications);
+
+ $listener->handle($event);
+
+ expect(StorefrontListenerOrder::$customerSpy->notifications)->toHaveCount($before + 1)
+ ->and(StorefrontListenerOrder::$customerSpy->notifications[$before])->toBeInstanceOf($notificationClass);
+ }
+});
+
+test('driver assignment listener ignores lifecycle events that do not resolve to an order', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('products')->insert(['uuid' => 'product_uuid']);
+ $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor();
+ $event->modelUuid = 'product_uuid';
+ $event->modelClassNamespace = Product::class;
+
+ expect((new HandleOrderDriverAssigned())->handle($event))->toBeNull();
+});
+
+test('network invite mailable derives sender network recipients and join URL', function () {
+ $network = new Network(['name' => 'Coffee Network']);
+ $sender = new User();
+ $sender->forceFill(['name' => 'Ada Admin']);
+ $invite = new Invite();
+ $invite->forceFill([
+ 'uri' => 'invite-code',
+ 'recipients' => ['merchant@example.test'],
+ ]);
+ $invite->setRelation('subject', $network);
+ $invite->setRelation('createdBy', $sender);
+
+ $mail = (new StorefrontNetworkInvite($invite))->build();
+
+ expect($mail->invite)->toBe($invite)
+ ->and($mail->network)->toBe($network)
+ ->and($mail->sender)->toBe($sender)
+ ->and($mail->url)->toContain('join/network/invite-code')
+ ->and($mail->subject)->toBe('You have been invited to join Coffee Network!')
+ ->and($mail->to[0]['address'])->toBe('merchant@example.test');
+});
diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php
new file mode 100644
index 00000000..e3c506e6
--- /dev/null
+++ b/server/tests/Unit/Models/CartTest.php
@@ -0,0 +1,300 @@
+public_id === $id ? static::$resolvedProduct : null;
+ }
+}
+
+function createCartLifecycleSchema(): void
+{
+ $schema = Capsule::schema('mysql');
+ $schema->dropIfExists('stores');
+ $schema->dropIfExists('carts');
+ $schema->create('stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('carts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('checkout_uuid')->nullable();
+ $table->string('customer_id')->nullable();
+ $table->string('unique_identifier')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('discount_code')->nullable();
+ $table->text('items')->nullable();
+ $table->text('events')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+function cartWithItems(): Cart
+{
+ $cart = new Cart();
+ $cart->forceFill([
+ 'currency' => 'USD',
+ 'items' => [
+ [
+ 'id' => 'cart_item_1',
+ 'store_id' => 'store_alpha',
+ 'quantity' => 2,
+ 'subtotal' => 2500,
+ ],
+ [
+ 'id' => 'cart_item_2',
+ 'store_id' => 'store_beta',
+ 'quantity' => 1,
+ 'subtotal' => 1750,
+ ],
+ [
+ 'id' => 'cart_item_3',
+ 'store_id' => 'store_alpha',
+ 'quantity' => 3,
+ 'subtotal' => 900,
+ ],
+ ],
+ 'events' => [
+ ['event' => 'cart.created', 'time' => 100],
+ ['event' => 'cart.item_added', 'time' => 200],
+ ],
+ ]);
+
+ return $cart;
+}
+
+test('cart serializes items and events while exposing commerce totals', function () {
+ $cart = cartWithItems();
+
+ expect($cart->getAttributes()['items'])->toBeJson()
+ ->and($cart->getAttributes()['events'])->toBeJson()
+ ->and($cart->items)->toHaveCount(3)
+ ->and($cart->events)->toHaveCount(2)
+ ->and($cart->subtotal)->toBe(5150)
+ ->and($cart->total_items)->toBe(6)
+ ->and($cart->total_unique_items)->toBe(3)
+ ->and($cart->last_event->event)->toBe('cart.item_added')
+ ->and($cart->is_multi_cart)->toBeTrue()
+ ->and($cart->checkout_store_id)->toBe('store_alpha')
+ ->and($cart->checkout_store_ids)->toBe(['store_alpha', 'store_beta']);
+});
+
+test('cart accessors accept already-decoded values and empty state', function () {
+ $cart = new Cart();
+
+ expect($cart->getItemsAttribute([(object) ['id' => 'item_1']]))->toHaveCount(1)
+ ->and($cart->getEventsAttribute([(object) ['event' => 'created']]))->toHaveCount(1);
+
+ $cart->setRawAttributes([
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+
+ expect($cart->items)->toBe([])
+ ->and($cart->events)->toBe([])
+ ->and($cart->subtotal)->toBe(0)
+ ->and($cart->total_items)->toBe(0)
+ ->and($cart->total_unique_items)->toBe(0)
+ ->and($cart->last_event)->toBeNull()
+ ->and($cart->is_multi_cart)->toBeFalse()
+ ->and($cart->checkout_store_id)->toBeNull()
+ ->and($cart->checkout_store_ids)->toBe([]);
+});
+
+test('cart scopes item and subtotal views to a storefront identifier or model', function () {
+ $cart = cartWithItems();
+ $store = new Store();
+ $store->forceFill(['public_id' => 'store_alpha']);
+
+ expect(array_values($cart->getItemsForStore('store_alpha')))->toHaveCount(2)
+ ->and(array_values($cart->getItemsForStore($store)))->toHaveCount(2)
+ ->and($cart->getSubtotalForStore('store_alpha'))->toBe(3400)
+ ->and($cart->getSubtotalForStore('store_beta'))->toBe(1750)
+ ->and($cart->getSubtotalForStore('store_missing'))->toBe(0);
+});
+
+test('cart finds items and indexes and records unsaved domain events', function () {
+ $cart = cartWithItems();
+
+ expect($cart->findCartItem('cart_item_2')->store_id)->toBe('store_beta')
+ ->and($cart->findCartItem('missing'))->toBeNull()
+ ->and($cart->findCartItemIndex('cart_item_3'))->toBe(2)
+ ->and($cart->findCartItemIndex('missing'))->toBe(-1)
+ ->and($cart->createEvent('cart.discount_applied', 'cart_item_1', false))->toBe($cart)
+ ->and($cart->last_event->event)->toBe('cart.discount_applied')
+ ->and($cart->last_event->cart_item_id)->toBe('cart_item_1');
+});
+
+test('cart currency behavior uses explicit session and caller fallback values', function () {
+ session(['storefront_currency' => 'MNT']);
+
+ $cart = new Cart();
+
+ expect($cart->updateCurrency(null, false))->toBe($cart)
+ ->and($cart->currency)->toBe('MNT')
+ ->and($cart->getCurrency('USD'))->toBe('MNT');
+
+ $cart->updateCurrency('EUR', false);
+
+ expect($cart->currency)->toBe('EUR')
+ ->and($cart->getCurrency('USD'))->toBe('EUR');
+
+ session(['storefront_currency' => null]);
+ $cart->setRawAttributes([]);
+
+ expect($cart->getCurrency('USD'))->toBe('USD');
+});
+
+test('cart calculates product subtotals across sale variant and addon pricing', function () {
+ $product = new Product();
+ $product->forceFill([
+ 'price' => 1000,
+ 'sale_price' => 800,
+ 'is_on_sale' => true,
+ ]);
+
+ expect(Cart::calculateProductSubtotal(
+ $product,
+ 2,
+ [
+ ['additional_cost' => 100],
+ ['additional_cost' => 50],
+ ],
+ [
+ ['price' => 200, 'is_on_sale' => false],
+ ['price' => 300, 'sale_price' => 125, 'is_on_sale' => true],
+ ]
+ ))->toBe(2550);
+
+ $product->is_on_sale = false;
+
+ expect(Cart::calculateProductSubtotal($product, 3))->toBe(3000);
+});
+
+test('cart rejects unsupported product and line-item inputs', function () {
+ expect(fn () => (new Cart())->add(new stdClass()))
+ ->toThrow(Exception::class, 'Invalid product provided to cart!');
+
+ expect(fn () => (new Cart())->updateItem([]))
+ ->toThrow(Exception::class, 'Invalid cart item provided to cart!');
+
+ expect(fn () => (new Cart())->remove([]))
+ ->toThrow(Exception::class, 'Invalid cart item provided to cart!');
+});
+
+test('cart exposes its package and cross-package relationship contracts', function () {
+ $cart = new Cart();
+
+ expect($cart->company()->getForeignKeyName())->toBe('company_uuid')
+ ->and($cart->user()->getForeignKeyName())->toBe('user_uuid')
+ ->and($cart->customer()->getForeignKeyName())->toBe('public_id')
+ ->and($cart->checkout()->getForeignKeyName())->toBe('checkout_uuid');
+});
+
+test('cart persists add update remove empty event and currency lifecycle behavior', function () {
+ createCartLifecycleSchema();
+ Capsule::connection('mysql')->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'currency' => 'USD',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ Capsule::connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $store = new Store();
+ $store->forceFill(['public_id' => 'store_public']);
+ $location = new StoreLocation();
+ $location->forceFill(['public_id' => 'location_default']);
+ $store->setRelation('locations', new EloquentCollection([$location]));
+ $product = new Product();
+ $product->forceFill([
+ 'public_id' => 'product_public',
+ 'store_uuid' => 'store_uuid',
+ 'name' => 'Express Delivery',
+ 'description'=> 'Same-day service',
+ 'price' => 1000,
+ 'sale_price' => 800,
+ 'is_on_sale' => true,
+ 'currency' => 'MNT',
+ 'meta' => ['fragile' => true],
+ ]);
+ $product->setRelation('store', $store);
+ $product->setRelation('primaryImage', null);
+ $product->setRelation('files', new EloquentCollection());
+ CartLifecycleStub::$resolvedProduct = $product;
+
+ $cart = CartLifecycleStub::where('uuid', 'cart_uuid')->firstOrFail();
+ $added = $cart->add(
+ 'product_public',
+ 2,
+ [['additional_cost' => 100]],
+ [['price' => 200, 'is_on_sale' => false]],
+ 'food_truck_mobile',
+ '2026-07-28 09:00:00',
+ 123
+ );
+
+ expect($added->store_id)->toBe('store_public')
+ ->and($added->food_truck_id)->toBe('food_truck_mobile')
+ ->and($added->store_location_id)->toBe('location_default')
+ ->and($added->price)->toBe(800)
+ ->and($added->subtotal)->toBe(2200)
+ ->and($added->created_at)->toBe(123)
+ ->and($cart->currency)->toBe('MNT')
+ ->and($cart->last_event->event)->toBe('cart.item_added');
+
+ $updated = $cart->updateItem(
+ $added->id,
+ 3,
+ [['additional_cost' => 50]],
+ [],
+ '2026-07-29 10:00:00'
+ );
+
+ expect($updated->quantity)->toBe(3)
+ ->and($updated->subtotal)->toBe(2550)
+ ->and($updated->scheduled_at)->toBe('2026-07-29 10:00:00')
+ ->and($cart->last_event->event)->toBe('cart.item_updated')
+ ->and($cart->updateCartItemById($added->id, 1)->quantity)->toBe(1)
+ ->and($cart->remove($added->id))->toBe($cart)
+ ->and($cart->items)->toBe([])
+ ->and($cart->last_event->event)->toBe('cart.item_removed');
+
+ $direct = $cart->addItem($product, storeLocationId: 'location_public');
+ expect($direct->store_location_id)->toBe('location_public')
+ ->and($cart->removeItemById($direct->id))->toBe($cart)
+ ->and($cart->addItem($product)->store_location_id)->toBe('location_default')
+ ->and($cart->createEvent('cart.saved'))->toBe($cart)
+ ->and($cart->updateCurrency('EUR', true))->toBe($cart)
+ ->and($cart->empty())->toBe($cart)
+ ->and($cart->currency)->toBeNull()
+ ->and($cart->last_event->event)->toBe('cart.emptied');
+});
diff --git a/server/tests/Unit/Models/CheckoutBehaviorTest.php b/server/tests/Unit/Models/CheckoutBehaviorTest.php
new file mode 100644
index 00000000..7c0b86f1
--- /dev/null
+++ b/server/tests/Unit/Models/CheckoutBehaviorTest.php
@@ -0,0 +1,97 @@
+dropIfExists('checkouts');
+ $schema->create('checkouts', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('cart_uuid')->nullable();
+ $table->string('token')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ app()->instance('responsecache', new class {
+ public function clear(): void
+ {
+ }
+ });
+ Illuminate\Database\Eloquent\Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Illuminate\Database\Eloquent\Model::clearBootedModels();
+
+ try {
+ $checkout = new Checkout();
+ $checkout->save();
+ } finally {
+ Illuminate\Database\Eloquent\Model::unsetEventDispatcher();
+ }
+
+ expect($checkout->token)->toStartWith('checkout_')
+ ->and(strlen($checkout->token))->toBe(41);
+});
+
+test('checkout exposes its accounting and storefront relationship contracts', function () {
+ $checkout = new Checkout();
+
+ expect($checkout->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->order())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->owner())->toBeInstanceOf(MorphTo::class)
+ ->and((new Checkout())->serviceQuote())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->network())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->gateway())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Checkout())->cart())->toBeInstanceOf(BelongsTo::class);
+});
+
+test('checkout finalization links the originating cart to the checkout exactly once', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('carts');
+ $schema->create('carts', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('checkout_uuid')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->dropIfExists('checkouts');
+ $schema->create('checkouts', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('cart_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ ]);
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('checkouts')->insert([
+ 'uuid' => 'checkout_uuid',
+ 'cart_uuid' => 'cart_uuid',
+ ]);
+
+ $checkout = Checkout::query()->findOrFail('checkout_uuid');
+
+ $checkout->checkedout();
+ $unloaded = new Checkout();
+ $unloaded->forceFill([
+ 'uuid' => 'checkout_uuid',
+ 'cart_uuid' => 'cart_uuid',
+ ]);
+ $unloaded->checkedout();
+ (new Checkout())->checkedout();
+
+ expect(
+ Illuminate\Database\Capsule\Manager::connection('mysql')
+ ->table('carts')
+ ->where('uuid', 'cart_uuid')
+ ->value('checkout_uuid')
+ )->toBe('checkout_uuid');
+});
diff --git a/server/tests/Unit/Models/CustomerTest.php b/server/tests/Unit/Models/CustomerTest.php
new file mode 100644
index 00000000..7abb5edf
--- /dev/null
+++ b/server/tests/Unit/Models/CustomerTest.php
@@ -0,0 +1,111 @@
+dropIfExists($table);
+ }
+ $schema->create('contacts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('_key')->nullable();
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('internal_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->string('photo_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('type')->nullable();
+ $table->string('slug')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('reviews', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('customer_uuid');
+ $table->string('subject_type')->nullable();
+ $table->softDeletes();
+ });
+ $schema->create('files', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uploader_uuid');
+ $table->string('type')->nullable();
+ $table->softDeletes();
+ });
+ $schema->create('orders', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('customer_uuid');
+ $table->text('meta')->nullable();
+ $table->softDeletes();
+ });
+
+ $connection = Capsule::connection('mysql');
+ $connection->table('contacts')->insert([
+ ['uuid' => 'customer_uuid', 'public_id' => 'contact_customer', 'name' => 'Ada Buyer', 'type' => 'customer'],
+ ['uuid' => 'vendor_uuid', 'public_id' => 'contact_vendor', 'name' => 'Vendor', 'type' => 'vendor'],
+ ]);
+ $connection->table('reviews')->insert([
+ ['customer_uuid' => 'customer_uuid', 'subject_type' => 'Fleetbase\Storefront\Models\Product'],
+ ['customer_uuid' => 'customer_uuid', 'subject_type' => 'Fleetbase\Storefront\Models\Store'],
+ ]);
+ $connection->table('files')->insert([
+ ['uploader_uuid' => 'customer_uuid', 'type' => 'storefront_review_upload'],
+ ['uploader_uuid' => 'customer_uuid', 'type' => 'avatar'],
+ ]);
+ $connection->table('orders')->insert([
+ ['customer_uuid' => 'customer_uuid', 'meta' => json_encode(['storefront_id' => 'store_public'])],
+ ['customer_uuid' => 'customer_uuid', 'meta' => json_encode(['storefront_id' => 'other_store'])],
+ ['customer_uuid' => 'other_customer', 'meta' => json_encode(['storefront_id' => 'store_public'])],
+ ]);
+
+ $customer = Customer::where('public_id', 'contact_customer')->firstOrFail();
+
+ expect(Customer::query()->count())->toBe(1)
+ ->and($customer->reviews())->toBeInstanceOf(HasMany::class)
+ ->and($customer->productReviews())->toBeInstanceOf(HasMany::class)
+ ->and($customer->storeReviews())->toBeInstanceOf(HasMany::class)
+ ->and($customer->reviewUploads())->toBeInstanceOf(HasMany::class)
+ ->and($customer->reviews_count)->toBe(2)
+ ->and($customer->productReviews()->count())->toBe(1)
+ ->and($customer->storeReviews()->count())->toBe(1)
+ ->and($customer->reviewUploads()->count())->toBe(1)
+ ->and($customer->countStorefrontOrdersFrom('store_public'))->toBe(1)
+ ->and(Customer::findFromCustomerId('customer_customer')?->uuid)->toBe('customer_uuid')
+ ->and(Customer::findFromCustomerId('contact_customer')?->uuid)->toBe('customer_uuid')
+ ->and(Customer::findFromCustomerId('customer_missing'))->toBeNull();
+});
+
+test('new customers are classified as customers before persistence', function () {
+ app()->instance('responsecache', new class {
+ public function clear(): void
+ {
+ }
+ });
+ Customer::setEventDispatcher(new Dispatcher(app()));
+ Customer::clearBootedModels();
+
+ $customer = new Customer();
+ $customer->forceFill([
+ 'uuid' => 'created_customer_uuid',
+ 'public_id' => 'contact_created',
+ 'name' => 'Created Customer',
+ ]);
+
+ $fireCreating = new ReflectionMethod($customer, 'fireModelEvent');
+ $fireCreating->invoke($customer, 'creating', false);
+
+ expect($customer->type)->toBe('customer');
+
+ Customer::unsetEventDispatcher();
+ Customer::clearBootedModels();
+});
diff --git a/server/tests/Unit/Models/NetworkBehaviorTest.php b/server/tests/Unit/Models/NetworkBehaviorTest.php
new file mode 100644
index 00000000..e9d2af2e
--- /dev/null
+++ b/server/tests/Unit/Models/NetworkBehaviorTest.php
@@ -0,0 +1,215 @@
+dropIfExists($table);
+ }
+ $schema->create('networks', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('network_stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('categories', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('owner_type')->nullable();
+ $table->string('parent_uuid')->nullable();
+ $table->string('icon_file_uuid')->nullable();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('icon')->nullable();
+ $table->string('icon_color')->nullable();
+ $table->string('slug')->nullable();
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('order_configs', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('namespace')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+test('network creation assigns a non-empty network key', function () {
+ createNetworkBehaviorSchema();
+ Network::setEventDispatcher(new Dispatcher(app()));
+ Network::clearBootedModels();
+
+ $network = new Network();
+ $network->forceFill(['uuid' => 'network_uuid', 'name' => 'Delivery Network']);
+ $fireCreating = new ReflectionMethod($network, 'fireModelEvent');
+ $fireCreating->invoke($network, 'creating', false);
+
+ expect($network->key)->toStartWith('network_')
+ ->and(strlen($network->key))->toBeGreaterThan(20);
+
+ Network::unsetEventDispatcher();
+ Network::clearBootedModels();
+});
+
+test('networks add stores idempotently and report active store counts', function () {
+ createNetworkBehaviorSchema();
+ $connection = Capsule::connection('mysql');
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'name' => 'Delivery Network',
+ ]);
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'name' => 'Store One'],
+ ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'name' => 'Store Two'],
+ ]);
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'name' => 'Food',
+ 'for' => 'network_category',
+ ]);
+
+ $network = Network::where('uuid', 'network_uuid')->firstOrFail();
+ $storeOne = Store::where('uuid', 'store_one_uuid')->firstOrFail();
+ $storeTwo = Store::where('uuid', 'store_two_uuid')->firstOrFail();
+ $category = Category::where('uuid', 'category_uuid')->firstOrFail();
+
+ $first = $network->addStore($storeOne, $category);
+ $same = $network->addStore($storeOne);
+ $network->addStore($storeTwo);
+ $connection->table('network_stores')->where('store_uuid', 'store_two_uuid')->update(['deleted_at' => now()]);
+
+ expect($first->category_uuid)->toBe('category_uuid')
+ ->and($same->network_uuid)->toBe($first->network_uuid)
+ ->and($same->store_uuid)->toBe($first->store_uuid)
+ ->and($connection->table('network_stores')->where('store_uuid', 'store_one_uuid')->count())->toBe(1)
+ ->and($network->stores_count)->toBe(1);
+});
+
+test('network categories preserve parent icon and strict uniqueness contracts', function () {
+ createNetworkBehaviorSchema();
+ $network = new Network();
+ $network->forceFill([
+ 'uuid' => 'network_uuid',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $parent = new Category();
+ $parent->forceFill(['uuid' => 'parent_uuid']);
+ $icon = new File();
+ $icon->forceFill(['uuid' => 'file_uuid']);
+
+ $withFile = $network->createCategory(
+ 'Groceries',
+ 'Everyday goods',
+ ['priority' => 1],
+ ['mn' => ['name' => 'Хүнс']],
+ $parent,
+ $icon,
+ '#123456'
+ );
+ $withName = $network->createCategory('Restaurants', icon: 'utensils');
+ $withoutIcon = $network->createCategory('Pharmacy');
+ $existing = $network->createCategoryStrict('Groceries', 'Changed description');
+ $created = $network->createCategoryStrict('Flowers');
+
+ expect($withFile->icon_file_uuid)->toBe('file_uuid')
+ ->and($withFile->parent_uuid)->toBe('parent_uuid')
+ ->and($withFile->meta)->toBe(['priority' => 1])
+ ->and($withName->icon)->toBe('utensils')
+ ->and($withoutIcon->icon_file_uuid)->toBeNull()
+ ->and($existing->name)->toBe($withFile->name)
+ ->and($existing->description)->toBe('Everyday goods')
+ ->and($created->name)->toBe('Flowers')
+ ->and(Capsule::connection('mysql')->table('categories')->count())->toBe(4);
+});
+
+test('network order config falls back to the company default and fails clearly without one', function () {
+ createNetworkBehaviorSchema();
+ Capsule::connection('mysql')->table('order_configs')->insert([
+ 'uuid' => 'config_default',
+ 'company_uuid' => 'network_company_uuid',
+ 'key' => 'storefront',
+ 'namespace' => 'system:order-config:storefront',
+ ]);
+ session(['company' => 'network_company_uuid']);
+
+ $network = new Network();
+ $network->setRelation('orderConfig', null);
+ $default = $network->getOrderConfig();
+
+ expect($default)->toBeInstanceOf(OrderConfig::class)
+ ->and($default->uuid)->toBe('config_default')
+ ->and($network->getRelation('orderConfig'))->toBe($default)
+ ->and($network->getOrderConfigId())->toBe('config_default');
+
+ session(['company' => null]);
+ $missing = new Network();
+ $missing->setRelation('orderConfig', null);
+
+ expect(fn () => $missing->getOrderConfig())
+ ->toThrow(RuntimeException::class, 'No default OrderConfig is configured.');
+});
+
+test('network options accept valid JSON and invitation relationships are exposed', function () {
+ $environmentRepository = new ReflectionProperty(Illuminate\Support\Env::class, 'repository');
+ $environmentRepository->setValue(null, new class {
+ public function get(string $key): mixed
+ {
+ return null;
+ }
+ });
+
+ $network = new Network();
+ $network->options = json_encode([
+ 'required_checkout_min_amount' => '$25.50',
+ 'pickup' => true,
+ ]);
+ $validOptions = $network->getAttributes()['options'];
+ $network->options = new stdClass();
+
+ expect($validOptions)->toBe([
+ 'required_checkout_min_amount' => 2550,
+ 'pickup' => true,
+ ])->and($network->getAttributes()['options'])->toBe([])
+ ->and($network->invitations())->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\HasMany::class);
+});
diff --git a/server/tests/Unit/Models/ProductBehaviorTest.php b/server/tests/Unit/Models/ProductBehaviorTest.php
new file mode 100644
index 00000000..91f8f402
--- /dev/null
+++ b/server/tests/Unit/Models/ProductBehaviorTest.php
@@ -0,0 +1,380 @@
+dropIfExists($table);
+ }
+ $schema->create('product_addon_categories', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('product_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->text('excluded_addons')->nullable();
+ $table->integer('max_selectable')->nullable();
+ $table->boolean('is_required')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('product_variants', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('product_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->boolean('is_multiselect')->nullable();
+ $table->boolean('is_required')->nullable();
+ $table->integer('min')->nullable();
+ $table->integer('max')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('product_variant_options', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('product_variant_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->integer('additional_cost')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+function createProductNetworkSearchSchema(): void
+{
+ $schema = Capsule::schema('mysql');
+ foreach (['network_stores', 'networks', 'products', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('networks', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('network_stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('products', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+test('product creation generates both barcode representations', function () {
+ $schema = Capsule::schema('mysql');
+ $schema->dropIfExists('products');
+ $schema->create('products', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ });
+ DNS2D::swap(new class {
+ public function getBarcodePNG(string $value, string $type): string
+ {
+ return $type . ':' . $value;
+ }
+ });
+ Product::setEventDispatcher(new Dispatcher(app()));
+ Product::clearBootedModels();
+
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+ $fireCreating = new ReflectionMethod($product, 'fireModelEvent');
+ $fireCreating->invoke($product, 'creating', false);
+
+ expect($product->qr_code)->toBe('QRCODE:product_uuid')
+ ->and($product->barcode)->toBe('PDF417:product_uuid');
+
+ Product::unsetEventDispatcher();
+ Product::clearBootedModels();
+});
+
+test('product normalizes money metadata and slug configuration', function () {
+ $product = new Product();
+ $product->forceFill([
+ 'meta' => [
+ 'preparationTime' => '15 minutes',
+ 'dietary_note' => 'Vegan',
+ ],
+ ]);
+ $product->price = '1,250';
+ $product->sale_price = '$900';
+
+ expect($product->price)->toBe(1250)
+ ->and($product->sale_price)->toBe(900)
+ ->and($product->meta_array)->toBe([
+ [
+ 'key' => 'preparation_time',
+ 'label' => 'PreparationTime',
+ 'value' => '15 minutes',
+ ],
+ [
+ 'key' => 'dietary_note',
+ 'label' => 'Dietary Note',
+ 'value' => 'Vegan',
+ ],
+ ])
+ ->and($product->getSlugOptions()->generateSlugFrom)->toBe(['name'])
+ ->and($product->getSlugOptions()->slugField)->toBe('slug');
+
+ $product->forceFill(['meta' => []]);
+
+ expect($product->meta_array)->toBe([]);
+});
+
+test('product primary image chooses primary secondary and fallback sources', function () {
+ $product = new Product();
+ $product->setRelation('primaryImage', null);
+ $product->setRelation('files', new Collection());
+
+ expect($product->primary_image_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png');
+
+ $secondary = new class(['url' => 'https://cdn.example.test/secondary.png']) extends Model {
+ protected $guarded = [];
+ };
+ $product->setRelation('files', new Collection([$secondary]));
+
+ expect($product->primary_image_url)->toBe('https://cdn.example.test/secondary.png');
+
+ $primary = new class(['url' => 'https://cdn.example.test/primary.png']) extends Model {
+ protected $guarded = [];
+ };
+ $product->setRelation('primaryImage', $primary);
+
+ expect($product->primary_image_url)->toBe('https://cdn.example.test/primary.png');
+});
+
+test('product converts commerce attributes into a Fleet-Ops entity contract', function () {
+ session(['company' => 'company_uuid']);
+ $product = new Product();
+ $product->forceFill([
+ 'public_id' => 'product_public',
+ 'primary_image_uuid'=> 'file_uuid',
+ 'name' => 'Coffee',
+ 'description' => 'Fresh coffee',
+ 'currency' => 'USD',
+ 'sku' => 'COF-1',
+ 'price' => 1200,
+ 'sale_price' => 1000,
+ ]);
+ $product->setRelation('primaryImage', null);
+ $product->setRelation('files', new Collection());
+
+ $entity = $product->toEntity([
+ 'weight' => 2,
+ 'meta' => ['fragile' => true],
+ ]);
+
+ expect($entity)->toBeInstanceOf(Entity::class)
+ ->and($entity->company_uuid)->toBe('company_uuid')
+ ->and($entity->internal_id)->toBe('product_public')
+ ->and($entity->name)->toBe('Coffee')
+ ->and($entity->weight)->toBe(2)
+ ->and(data_get($entity->meta, 'product_id'))->toBe('product_public')
+ ->and(data_get($entity->meta, 'fragile'))->toBeTrue();
+});
+
+test('product exposes commerce relationship contracts', function () {
+ $product = new Product();
+
+ expect($product->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and($product->category())->toBeInstanceOf(BelongsTo::class)
+ ->and($product->addonCategories())->toBeInstanceOf(HasMany::class)
+ ->and($product->variants())->toBeInstanceOf(HasMany::class)
+ ->and($product->primaryImage())->toBeInstanceOf(BelongsTo::class)
+ ->and($product->files())->toBeInstanceOf(HasMany::class)
+ ->and($product->reviews())->toBeInstanceOf(HasMany::class)
+ ->and($product->votes())->toBeInstanceOf(HasMany::class)
+ ->and($product->hours())->toBeInstanceOf(HasMany::class)
+ ->and($product->store())->toBeInstanceOf(BelongsTo::class)
+ ->and($product->catalogCategories())->toBeInstanceOf(BelongsToMany::class);
+});
+
+test('product addon categories update existing assignments and create new ones', function () {
+ createProductMutationSchema();
+ $existingUuid = '3f7d6df9-a4ba-42d5-8cf4-0e568d4fdca9';
+ Capsule::connection('mysql')->table('product_addon_categories')->insert([
+ 'uuid' => $existingUuid,
+ 'product_uuid' => 'product_uuid',
+ 'category_uuid' => 'old_category',
+ 'excluded_addons' => '[]',
+ 'max_selectable' => 1,
+ 'is_required' => false,
+ ]);
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+
+ expect($product->setAddonCategories([
+ [
+ 'uuid' => $existingUuid,
+ 'category_uuid' => 'updated_category',
+ 'excluded_addons' => ['addon_blocked'],
+ 'max_selectable' => 2,
+ 'is_required' => true,
+ ],
+ [
+ 'category_uuid' => 'new_category',
+ 'excluded_addons' => [],
+ 'max_selectable' => 3,
+ 'is_required' => false,
+ ],
+ ]))->toBe($product);
+
+ $existing = ProductAddonCategory::where('uuid', $existingUuid)->firstOrFail();
+ $created = ProductAddonCategory::where('category_uuid', 'new_category')->firstOrFail();
+
+ expect($existing->category_uuid)->toBe('updated_category')
+ ->and($existing->excluded_addons)->toBe(['addon_blocked'])
+ ->and($existing->max_selectable)->toBe(2)
+ ->and($existing->is_required)->toBeTrue()
+ ->and($created->product_uuid)->toBe('product_uuid')
+ ->and($created->max_selectable)->toBe(3);
+});
+
+test('product variants normalize null option costs while updating and creating nested options', function () {
+ createProductMutationSchema();
+ session(['user' => 'user_uuid', 'company' => 'company_uuid']);
+ $variantUuid = '6f6d167d-11f3-4e2f-995d-8307b6134960';
+ $optionUuid = '4d3d0949-8383-4bbf-a720-047717d76125';
+ $createdVariantUuid= 'cb3453fc-f25c-4c50-a79e-7d9fffbfd39f';
+ Capsule::connection('mysql')->table('product_variants')->insert([
+ 'uuid' => $variantUuid,
+ 'product_uuid' => 'product_uuid',
+ 'name' => 'Size',
+ ]);
+ Capsule::connection('mysql')->table('product_variant_options')->insert([
+ 'uuid' => $optionUuid,
+ 'product_variant_uuid' => $variantUuid,
+ 'name' => 'Small',
+ 'additional_cost' => 100,
+ ]);
+
+ ProductVariant::setEventDispatcher(new Dispatcher(app()));
+ app()->instance('responsecache', new class {
+ public function clear(): void
+ {
+ }
+ });
+ ProductVariant::creating(function (ProductVariant $variant) use ($createdVariantUuid) {
+ $variant->uuid = $createdVariantUuid;
+ });
+ $product = new Product();
+ $product->forceFill(['uuid' => 'product_uuid']);
+
+ expect($product->setProductVariants([
+ [
+ 'uuid' => $variantUuid,
+ 'name' => 'Package Size',
+ 'is_multiselect' => false,
+ 'is_required' => true,
+ 'options' => [
+ [
+ 'uuid' => $optionUuid,
+ 'name' => 'Small Updated',
+ 'additional_cost' => null,
+ ],
+ [
+ 'name' => 'Large',
+ 'additional_cost' => 500,
+ ],
+ ],
+ ],
+ [
+ 'name' => 'Temperature',
+ 'is_multiselect' => false,
+ 'is_required' => false,
+ 'options' => [
+ ['name' => 'Cold', 'additional_cost' => 250],
+ ],
+ ],
+ ]))->toBe($product);
+
+ $updatedOption = ProductVariantOption::where('uuid', $optionUuid)->firstOrFail();
+ $newOption = ProductVariantOption::where('name', 'Large')->firstOrFail();
+ $createdOption = ProductVariantOption::where('name', 'Cold')->firstOrFail();
+
+ expect($updatedOption->additional_cost)->toBe(0)
+ ->and($newOption->product_variant_uuid)->toBe($variantUuid)
+ ->and($createdOption->product_variant_uuid)->toBe($createdVariantUuid);
+
+ ProductVariant::unsetEventDispatcher();
+ ProductVariant::clearBootedModels();
+});
+
+test('product network search enforces network store availability status and limit filters', function () {
+ createProductNetworkSearchSchema();
+ $connection = Capsule::connection('mysql');
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'name' => 'One'],
+ ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'name' => 'Two'],
+ ]);
+ $connection->table('networks')->insert([
+ ['uuid' => 'network_one', 'public_id' => 'network_public_one'],
+ ['uuid' => 'network_two', 'public_id' => 'network_public_two'],
+ ]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_one', 'store_uuid' => 'store_one_uuid'],
+ ['network_uuid' => 'network_two', 'store_uuid' => 'store_two_uuid'],
+ ]);
+ $connection->table('products')->insert([
+ ['uuid' => 'product_one', 'public_id' => 'product_one', 'store_uuid' => 'store_one_uuid', 'name' => 'Fresh Coffee', 'description' => 'Arabica', 'is_available' => 1, 'status' => 'published'],
+ ['uuid' => 'product_two', 'public_id' => 'product_two', 'store_uuid' => 'store_one_uuid', 'name' => 'Fresh Tea', 'description' => 'Green', 'is_available' => 1, 'status' => 'published'],
+ ['uuid' => 'product_hidden', 'public_id' => 'product_hidden', 'store_uuid' => 'store_one_uuid', 'name' => 'Fresh Hidden', 'description' => 'Draft', 'is_available' => 1, 'status' => 'draft'],
+ ['uuid' => 'product_other', 'public_id' => 'product_other', 'store_uuid' => 'store_two_uuid', 'name' => 'Fresh Coffee', 'description' => 'Other network', 'is_available' => 1, 'status' => 'published'],
+ ]);
+ session(['storefront_network' => 'network_one']);
+
+ $all = Product::findFromNetwork('Fresh');
+ $limited = Product::findFromNetwork('Fresh', 'store_one', 1);
+
+ expect($all->pluck('public_id')->all())->toBe(['product_one', 'product_two'])
+ ->and($limited)->toHaveCount(1)
+ ->and($limited->first()->store_uuid)->toBe('store_one_uuid');
+});
diff --git a/server/tests/Unit/Models/StoreBehaviorTest.php b/server/tests/Unit/Models/StoreBehaviorTest.php
new file mode 100644
index 00000000..b8e473a8
--- /dev/null
+++ b/server/tests/Unit/Models/StoreBehaviorTest.php
@@ -0,0 +1,425 @@
+dropIfExists($table);
+ }
+ $schema->create('stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('networks', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('network_stores', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('categories', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('owner_type')->nullable();
+ $table->string('parent_uuid')->nullable();
+ $table->string('icon_file_uuid')->nullable();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->text('meta')->nullable();
+ $table->string('icon')->nullable();
+ $table->string('icon_color')->nullable();
+ $table->string('slug')->nullable();
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('products', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('sku')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->nullable();
+ $table->string('currency')->nullable();
+ $table->boolean('is_service')->default(false);
+ $table->boolean('is_bookable')->default(false);
+ $table->boolean('is_available')->default(true);
+ $table->boolean('is_on_sale')->default(false);
+ $table->boolean('is_recommended')->default(false);
+ $table->boolean('can_pickup')->default(false);
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('store_locations', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid');
+ $table->string('created_by_uuid')->nullable();
+ $table->string('place_uuid');
+ $table->string('name');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('order_configs', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('namespace')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+}
+
+test('store creation assigns a non-empty store key', function () {
+ createStoreBehaviorSchema();
+ Store::setEventDispatcher(new Dispatcher(app()));
+ Store::clearBootedModels();
+
+ $store = new Store();
+ $fireCreating = new ReflectionMethod($store, 'fireModelEvent');
+ $fireCreating->invoke($store, 'creating', false);
+
+ expect($store->key)->toStartWith('store_')
+ ->and(strlen($store->key))->toBeGreaterThan(20);
+
+ Store::unsetEventDispatcher();
+ Store::clearBootedModels();
+});
+
+test('store normalizes options and exposes stable media fallbacks and slug configuration', function () {
+ $store = new Store();
+ $store->setOptionsAttribute(json_encode([
+ 'required_checkout_min_amount' => '1,250',
+ 'allow_pickup' => true,
+ ]));
+
+ expect(json_decode($store->getAttributes()['options'], true))->toBe([
+ 'required_checkout_min_amount' => 1250,
+ 'allow_pickup' => true,
+ ])->and($store->logo_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png')
+ ->and($store->backdrop_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/default-storefront-backdrop.png')
+ ->and($store->getSlugOptions()->generateSlugFrom)->toBe(['name'])
+ ->and($store->getSlugOptions()->slugField)->toBe('slug');
+
+ $store->setOptionsAttribute(new stdClass());
+
+ expect(json_decode($store->getAttributes()['options'], true))->toBe([]);
+});
+
+test('store rating and checkout counters reflect recent persisted activity', function () {
+ Carbon::setTestNow('2026-07-26 12:00:00');
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('reviews');
+ $schema->dropIfExists('checkouts');
+ $schema->create('reviews', function ($table) {
+ $table->increments('id');
+ $table->string('subject_uuid');
+ $table->integer('rating');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('checkouts', function ($table) {
+ $table->increments('id');
+ $table->string('store_uuid');
+ $table->timestamp('created_at');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('reviews')->insert([
+ ['subject_uuid' => 'store_uuid', 'rating' => 5],
+ ['subject_uuid' => 'store_uuid', 'rating' => 3],
+ ['subject_uuid' => 'other_store', 'rating' => 1],
+ ]);
+ $connection->table('checkouts')->insert([
+ ['store_uuid' => 'store_uuid', 'created_at' => now()->subHour()],
+ ['store_uuid' => 'store_uuid', 'created_at' => now()->subDays(5)],
+ ['store_uuid' => 'store_uuid', 'created_at' => now()->subMonths(2)],
+ ]);
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid']);
+
+ expect($store->rating)->toBe(4.0)
+ ->and($store->this_month_checkouts_count)->toBe(2)
+ ->and($store->{'24h_checkouts_count'})->toBe(1);
+
+ Carbon::setTestNow();
+});
+
+test('store network category lookup short circuits missing identifiers', function () {
+ expect((new Store())->getNetworkCategoryUsingId(null))->toBeNull();
+});
+
+test('store exposes its relationship contracts', function () {
+ expect((new Store())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Store())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Store())->logo())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Store())->backdrop())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Store())->orderConfig())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Store())->files())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->media())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->categories())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->products())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->checkouts())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->hours())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->reviews())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->votes())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->notificationChannels())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->gateways())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->locations())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->networkStores())->toBeInstanceOf(HasMany::class)
+ ->and((new Store())->networks())->toBeInstanceOf(BelongsToMany::class);
+});
+
+test('store resolves an assigned order configuration without a database lookup', function () {
+ $config = new OrderConfig();
+ $config->forceFill(['uuid' => 'config_uuid']);
+
+ $store = new Store();
+ $store->setRelation('orderConfig', $config);
+
+ expect($store->getOrderConfig())->toBe($config)
+ ->and($store->getOrderConfigId())->toBe('config_uuid');
+
+ $store->forceFill(['order_config_uuid' => 'direct_config_uuid']);
+
+ expect($store->getOrderConfigId())->toBe('direct_config_uuid');
+});
+
+test('store returns no category when it is not assigned to a network', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('network_stores');
+ $schema->dropIfExists('networks');
+ $schema->create('networks', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $network = new Network();
+ $network->forceFill(['uuid' => 'network_uuid']);
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid']);
+
+ expect($store->getNetworkCategory($network))->toBeNull()
+ ->and($store->getNetworkCategoryUsingId('missing_network'))->toBeNull();
+});
+
+test('store resolves its category assignment using network uuid and public id', function () {
+ createStoreBehaviorSchema();
+ $connection = Capsule::connection('mysql');
+ $connection->table('stores')->insert(['uuid' => 'store_uuid', 'name' => 'Store']);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'name' => 'Network',
+ ]);
+ $connection->table('categories')->insert([
+ 'uuid' => 'category_uuid',
+ 'name' => 'Groceries',
+ ]);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'store_uuid',
+ 'category_uuid' => 'category_uuid',
+ ]);
+
+ $store = Store::where('uuid', 'store_uuid')->firstOrFail();
+ $network = Network::where('uuid', 'network_uuid')->firstOrFail();
+
+ expect($store->getNetworkCategory($network)?->uuid)->toBe('category_uuid')
+ ->and($store->getNetworkCategoryUsingId('network_uuid')?->uuid)->toBe('category_uuid')
+ ->and($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid');
+});
+
+test('store categories preserve ownership parent icon and strict uniqueness contracts', function () {
+ createStoreBehaviorSchema();
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $parent = new Category();
+ $parent->forceFill(['uuid' => 'parent_uuid']);
+ $icon = new File();
+ $icon->forceFill(['uuid' => 'file_uuid']);
+
+ $withFile = $store->createCategory(
+ 'Groceries',
+ 'Everyday goods',
+ ['priority' => 1],
+ ['mn' => ['name' => 'Хүнс']],
+ $parent,
+ $icon,
+ '#123456'
+ );
+ $withName = $store->createCategory('Restaurants', icon: 'utensils');
+ $withoutIcon = $store->createCategory('Pharmacy');
+ $existing = $store->createCategoryStrict('Groceries', 'Changed description');
+ $created = $store->createCategoryStrict('Flowers');
+
+ expect($withFile->owner_uuid)->toBe('store_uuid')
+ ->and($withFile->icon_file_uuid)->toBe('file_uuid')
+ ->and($withFile->parent_uuid)->toBe('parent_uuid')
+ ->and($withFile->meta)->toBe(['priority' => 1])
+ ->and($withName->icon)->toBe('utensils')
+ ->and($withoutIcon->icon_file_uuid)->toBeNull()
+ ->and($existing->description)->toBe('Everyday goods')
+ ->and($created->name)->toBe('Flowers')
+ ->and(Capsule::connection('mysql')->table('categories')->count())->toBe(4);
+});
+
+test('store creates configured products and applies safe defaults', function () {
+ createStoreBehaviorSchema();
+ $store = new Store();
+ $store->forceFill([
+ 'uuid' => 'store_uuid',
+ 'company_uuid' => 'company_uuid',
+ 'currency' => 'MNT',
+ ]);
+ $category = new Category();
+ $category->forceFill(['uuid' => 'category_uuid']);
+ $image = new File();
+ $image->forceFill(['uuid' => 'image_uuid']);
+ $user = new User();
+ $user->forceFill(['uuid' => 'user_uuid']);
+
+ $configured = $store->createProduct(
+ 'Delivery',
+ 'Same-day delivery',
+ ['express'],
+ $category,
+ $image,
+ $user,
+ 'SKU-1',
+ 1200,
+ 'available',
+ [
+ 'sale_price' => 900,
+ 'is_service' => true,
+ 'is_bookable' => true,
+ 'is_available' => false,
+ 'is_on_sale' => true,
+ 'is_recommended' => true,
+ 'can_pickup' => true,
+ ]
+ );
+ $default = $store->createProduct('Box', 'Standard box');
+
+ expect($configured->store_uuid)->toBe('store_uuid')
+ ->and($configured->company_uuid)->toBe('company_uuid')
+ ->and($configured->primary_image_uuid)->toBe('image_uuid')
+ ->and($configured->created_by_uuid)->toBe('user_uuid')
+ ->and($configured->category_uuid)->toBe('category_uuid')
+ ->and($configured->currency)->toBe('MNT')
+ ->and($configured->sale_price)->toBe(900)
+ ->and($configured->is_service)->toBeTrue()
+ ->and($configured->is_bookable)->toBeTrue()
+ ->and($configured->is_available)->toBeFalse()
+ ->and($configured->is_on_sale)->toBeTrue()
+ ->and($configured->is_recommended)->toBeTrue()
+ ->and($configured->can_pickup)->toBeTrue()
+ ->and($default->sale_price)->toBe(0)
+ ->and($default->is_available)->toBeTrue()
+ ->and($default->is_service)->toBeFalse();
+});
+
+test('store creates named and default locations only for resolvable places', function () {
+ createStoreBehaviorSchema();
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'name' => 'Central']);
+ $place = new Place();
+ $place->forceFill(['uuid' => 'place_uuid']);
+ $user = new User();
+ $user->forceFill(['uuid' => 'user_uuid']);
+
+ $named = $store->createLocation($place, 'Pickup', $user);
+ $default = $store->createLocation($place, null, null);
+
+ expect($named)->toBeInstanceOf(StoreLocation::class)
+ ->and($named->name)->toBe('Pickup')
+ ->and($named->created_by_uuid)->toBe('user_uuid')
+ ->and($default?->name)->toBe('Central store location')
+ ->and($store->createLocation(42, null, null))->toBeNull();
+});
+
+test('store order config falls back to the company default and fails clearly without one', function () {
+ createStoreBehaviorSchema();
+ Capsule::connection('mysql')->table('order_configs')->insert([
+ 'uuid' => 'config_default',
+ 'company_uuid' => 'store_company_uuid',
+ 'key' => 'storefront',
+ 'namespace' => 'system:order-config:storefront',
+ ]);
+ session(['company' => 'store_company_uuid']);
+
+ $store = new Store();
+ $store->setRelation('orderConfig', null);
+ $default = $store->getOrderConfig();
+
+ expect($default)->toBeInstanceOf(OrderConfig::class)
+ ->and($default->uuid)->toBe('config_default')
+ ->and($store->getRelation('orderConfig'))->toBe($default)
+ ->and($store->getOrderConfigId())->toBe('config_default');
+
+ session(['company' => null]);
+ $missing = new Store();
+ $missing->setRelation('orderConfig', null);
+
+ expect(fn () => $missing->getOrderConfig())
+ ->toThrow(RuntimeException::class, 'No default OrderConfig is configured.');
+});
diff --git a/server/tests/Unit/Models/StorefrontModelContractsTest.php b/server/tests/Unit/Models/StorefrontModelContractsTest.php
new file mode 100644
index 00000000..7f158bad
--- /dev/null
+++ b/server/tests/Unit/Models/StorefrontModelContractsTest.php
@@ -0,0 +1,598 @@
+createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Catalog())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Catalog())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Catalog())->hours())->toBeInstanceOf(HasMany::class)
+ ->and((new Catalog())->categories())->toBeInstanceOf(HasMany::class)
+ ->and((new Catalog())->assignments())->toBeInstanceOf(HasMany::class)
+ ->and((new Catalog())->subjects())->toBeInstanceOf(MorphToMany::class);
+});
+
+test('catalog synchronizes category updates removals and additions', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('categories');
+ $schema->dropIfExists('catalog_category_products');
+ $schema->dropIfExists('products');
+ $schema->create('categories', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('owner_type')->nullable();
+ $table->string('name');
+ $table->string('for')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('catalog_category_products', function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('catalog_category_uuid')->nullable();
+ $table->string('product_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->string('uuid')->primary();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $keepUuid = '70000000-0000-4000-8000-000000000001';
+ $removeUuid = '70000000-0000-4000-8000-000000000002';
+ $connection->table('categories')->insert([
+ [
+ 'uuid' => $keepUuid,
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'catalog_uuid',
+ 'owner_type' => Catalog::class,
+ 'name' => 'Old name',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => $removeUuid,
+ 'company_uuid' => 'company_uuid',
+ 'owner_uuid' => 'catalog_uuid',
+ 'owner_type' => Catalog::class,
+ 'name' => 'Remove me',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+
+ $keep = CatalogCategory::where('uuid', $keepUuid)->firstOrFail();
+ $keep->setRelation('products', collect());
+ $remove = CatalogCategory::where('uuid', $removeUuid)->firstOrFail();
+
+ $catalog = new Catalog([
+ 'uuid' => 'catalog_uuid',
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $catalog->setRelation('categories', collect([$keep, $remove]));
+
+ expect($catalog->setCategories([
+ ['uuid' => $keepUuid, 'name' => 'Updated name', 'products' => []],
+ ['name' => 'New category', 'products' => []],
+ ]))->toBe($catalog);
+
+ expect($connection->table('categories')->where('uuid', $keepUuid)->value('name'))->toBe('Updated name')
+ ->and($connection->table('categories')->where('uuid', $removeUuid)->value('deleted_at'))->not->toBeNull()
+ ->and($connection->table('categories')->where('name', 'New category')->exists())->toBeTrue();
+});
+
+test('catalog category exposes catalog product and polymorphic ownership relationships', function () {
+ expect((new CatalogCategory())->owner())->toBeInstanceOf(MorphTo::class)
+ ->and((new CatalogCategory())->catalog())->toBeInstanceOf(BelongsTo::class)
+ ->and((new CatalogCategory())->products())->toBeInstanceOf(BelongsToMany::class);
+});
+
+test('catalog category synchronizes valid unique product assignments', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('catalog_category_products');
+ $schema->create('catalog_category_products', function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('catalog_category_uuid');
+ $table->string('product_uuid');
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $categoryUuid = '10000000-0000-4000-8000-000000000001';
+ $keepUuid = '20000000-0000-4000-8000-000000000001';
+ $removeUuid = '20000000-0000-4000-8000-000000000002';
+ $addUuid = '20000000-0000-4000-8000-000000000003';
+
+ $connection->table('catalog_category_products')->insert([
+ [
+ 'uuid' => '30000000-0000-4000-8000-000000000001',
+ 'catalog_category_uuid' => $categoryUuid,
+ 'product_uuid' => $keepUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => '30000000-0000-4000-8000-000000000002',
+ 'catalog_category_uuid' => $categoryUuid,
+ 'product_uuid' => $removeUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+
+ $category = new CatalogCategory();
+ $category->forceFill(['uuid' => $categoryUuid]);
+ $category->setRelation('products', collect());
+
+ expect($category->setProducts([
+ $keepUuid,
+ ['uuid' => $addUuid],
+ (object) ['uuid' => $addUuid],
+ 'not-a-uuid',
+ ['uuid' => null],
+ ]))->toBe($category);
+
+ $active = $connection->table('catalog_category_products')
+ ->where('catalog_category_uuid', $categoryUuid)
+ ->whereNull('deleted_at')
+ ->orderBy('product_uuid')
+ ->pluck('product_uuid')
+ ->all();
+
+ expect($active)->toBe([$keepUuid, $addUuid])
+ ->and($connection->table('catalog_category_products')
+ ->where('product_uuid', $removeUuid)
+ ->whereNotNull('deleted_at')
+ ->exists())->toBeTrue();
+});
+
+test('catalog pivot models expose their configured relationship contracts', function () {
+ $product = new CatalogProduct();
+ $subject = new CatalogSubject();
+
+ expect($product->getTable())->toBe('catalog_category_products')
+ ->and($product->getKeyName())->toBe('uuid')
+ ->and($product->getIncrementing())->toBeFalse()
+ ->and($subject->getTable())->toBe('catalog_subjects')
+ ->and($subject->subject())->toBeInstanceOf(MorphTo::class)
+ ->and($subject->catalog())->toBeInstanceOf(BelongsTo::class);
+});
+
+test('food truck exposes logistics catalog and ownership relationships', function () {
+ expect((new FoodTruck())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new FoodTruck())->vehicle())->toBeInstanceOf(BelongsTo::class)
+ ->and((new FoodTruck())->serviceArea())->toBeInstanceOf(BelongsTo::class)
+ ->and((new FoodTruck())->zone())->toBeInstanceOf(BelongsTo::class)
+ ->and((new FoodTruck())->catalogAssignments())->toBeInstanceOf(MorphMany::class)
+ ->and((new FoodTruck())->catalogs())->toBeInstanceOf(MorphToMany::class);
+});
+
+test('food truck derives location and assigned driver from a loaded vehicle', function () {
+ $driver = new Fleetbase\FleetOps\Models\Driver(['name' => 'Morgan Driver']);
+ $point = new Fleetbase\LaravelMysqlSpatial\Types\Point(47.9184, 106.9177);
+ $vehicle = new Vehicle();
+ $vehicle->setRelation('driver', $driver);
+ $vehicle->setAttribute('location', $point);
+
+ $foodTruck = new FoodTruck();
+ $foodTruck->setRelation('vehicle', $vehicle);
+
+ expect($foodTruck->location)->toBe($point)
+ ->and($foodTruck->getDriverAssigned())->toBe($driver);
+});
+
+test('food truck synchronizes catalog assignments and restores soft-deleted matches', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('catalog_subjects');
+ $schema->create('catalog_subjects', function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('catalog_uuid');
+ $table->string('subject_type');
+ $table->string('subject_uuid');
+ $table->string('company_uuid')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+
+ $truckUuid = '40000000-0000-4000-8000-000000000001';
+ $removeUuid = '50000000-0000-4000-8000-000000000001';
+ $keepUuid = '50000000-0000-4000-8000-000000000002';
+ $addUuid = '50000000-0000-4000-8000-000000000003';
+
+ $connection->table('catalog_subjects')->insert([
+ [
+ 'uuid' => '60000000-0000-4000-8000-000000000001',
+ 'catalog_uuid' => $removeUuid,
+ 'subject_type' => FoodTruck::class,
+ 'subject_uuid' => $truckUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => '60000000-0000-4000-8000-000000000002',
+ 'catalog_uuid' => $keepUuid,
+ 'subject_type' => FoodTruck::class,
+ 'subject_uuid' => $truckUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+
+ $remove = new Catalog(['uuid' => $removeUuid]);
+ $keep = new Catalog(['uuid' => $keepUuid]);
+ $truck = new FoodTruck([
+ 'uuid' => $truckUuid,
+ 'company_uuid' => 'company_uuid',
+ ]);
+ $truck->setRelation('catalogs', collect([$remove, $keep]));
+ session(['user' => 'user_uuid']);
+
+ expect($truck->setCatalogs([$keep, ['uuid' => $addUuid], $addUuid, 'invalid']))->toBe($truck);
+
+ $active = $connection->table('catalog_subjects')
+ ->where('subject_uuid', $truckUuid)
+ ->whereNull('deleted_at')
+ ->orderBy('catalog_uuid')
+ ->pluck('catalog_uuid')
+ ->all();
+
+ expect($active)->toBe([$keepUuid, $addUuid])
+ ->and($connection->table('catalog_subjects')
+ ->where('catalog_uuid', $removeUuid)
+ ->whereNotNull('deleted_at')
+ ->exists())->toBeTrue();
+});
+
+test('network exposes media commerce and ownership relationships', function () {
+ expect((new Network())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Network())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Network())->logo())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Network())->backdrop())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Network())->orderConfig())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Network())->files())->toBeInstanceOf(HasMany::class)
+ ->and((new Network())->media())->toBeInstanceOf(HasMany::class)
+ ->and((new Network())->stores())->toBeInstanceOf(BelongsToMany::class)
+ ->and((new Network())->notificationChannels())->toBeInstanceOf(HasMany::class)
+ ->and((new Network())->gateways())->toBeInstanceOf(HasMany::class)
+ ->and((new Network())->categories())->toBeInstanceOf(HasMany::class);
+});
+
+test('network asset accessors use loaded files and stable fallbacks', function () {
+ $network = new Network();
+
+ expect($network->logo_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png')
+ ->and($network->backdrop_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/default-storefront-backdrop.png');
+
+ $network->setRelation('logo', (object) ['url' => 'https://cdn.example.test/logo.png']);
+ $network->setRelation('backdrop', (object) ['url' => 'https://cdn.example.test/backdrop.png']);
+
+ expect($network->logo_url)->toBe('https://cdn.example.test/logo.png')
+ ->and($network->backdrop_url)->toBe('https://cdn.example.test/backdrop.png');
+});
+
+test('network normalizes checkout minimum options and invalid inputs', function () {
+ $network = new Network();
+ $network->options = ['required_checkout_min_amount' => '$1,234.50', 'pickup' => true];
+
+ expect($network->getAttributes()['options'])->toBe([
+ 'required_checkout_min_amount' => 123450,
+ 'pickup' => true,
+ ]);
+
+ $network->options = 'not-json';
+
+ expect($network->getAttributes()['options'])->toBe([]);
+});
+
+test('network order config uses direct foreign keys and loaded relations', function () {
+ $network = new Network(['order_config_uuid' => 'config_direct']);
+
+ expect($network->getOrderConfigId())->toBe('config_direct');
+
+ $config = new OrderConfig();
+ $config->forceFill(['uuid' => 'config_loaded']);
+
+ $network = new Network();
+ $network->setRelation('orderConfig', $config);
+
+ expect($network->getOrderConfig())->toBe($config)
+ ->and($network->getOrderConfigId())->toBe('config_loaded');
+});
+
+test('network slug configuration uses the name and slug columns', function () {
+ $options = (new Network())->getSlugOptions();
+
+ expect($options->generateSlugFrom)->toBe(['name'])
+ ->and($options->slugField)->toBe('slug');
+});
+
+test('supporting storefront models expose their relationship contracts', function () {
+ expect((new Fleetbase\Storefront\Models\AddonCategory())->addons())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\CatalogHour())->catalog())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NetworkStore())->network())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NetworkStore())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NetworkStore())->category())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\PaymentMethod())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\PaymentMethod())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\PaymentMethod())->owner())->toBeInstanceOf(MorphTo::class)
+ ->and((new Fleetbase\Storefront\Models\PaymentMethod())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\PaymentMethod())->gateway())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductAddon())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductAddon())->category())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductAddon())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductAddonCategory())->category())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductAddonCategory())->product())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductHour())->product())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductStoreLocation())->product())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductStoreLocation())->storeLocation())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductVariant())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductVariant())->category())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductVariant())->product())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\ProductVariant())->options())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\ProductVariantOption())->productVariant())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\StoreHour())->storeLocation())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\StoreLocation())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\StoreLocation())->place())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\StoreLocation())->store())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\StoreLocation())->hours())->toBeInstanceOf(HasMany::class);
+});
+
+test('store locations expose cached addresses and resolved place coordinates', function () {
+ $location = new Fleetbase\Storefront\Models\StoreLocation();
+ $location->setRelation('place', (object) [
+ 'address' => '1 Market Street',
+ ]);
+
+ $locationWithCoordinates = new class extends Fleetbase\Storefront\Models\StoreLocation {
+ public function place()
+ {
+ return new class {
+ public function first(): object
+ {
+ return (object) ['location' => 'POINT(106.9 47.9)'];
+ }
+ };
+ }
+ };
+
+ expect($location->address)->toBe('1 Market Street')
+ ->and($locationWithCoordinates->location)->toBe('POINT(106.9 47.9)');
+});
+
+test('review and vote models expose actor media and polymorphic subject relationships', function () {
+ $review = new Fleetbase\Storefront\Models\Review();
+
+ expect($review->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->customer())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->votes())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->files())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->photos())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->videos())->toBeInstanceOf(HasMany::class)
+ ->and((new Fleetbase\Storefront\Models\Review())->subject())->toBeInstanceOf(MorphTo::class)
+ ->and((new Fleetbase\Storefront\Models\Vote())->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Vote())->customer())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Vote())->subject())->toBeInstanceOf(MorphTo::class);
+});
+
+test('product pricing option and slug mutators normalize API inputs', function () {
+ $addon = new Fleetbase\Storefront\Models\ProductAddon();
+ $addon->price = '$1,234.50';
+ $addon->sale_price = 'USD 999.25';
+
+ expect($addon->getAttributes()['price'])->toBe(123450)
+ ->and($addon->getAttributes()['sale_price'])->toBe(99925);
+
+ $option = new Fleetbase\Storefront\Models\ProductVariantOption();
+ $option->additional_cost = '₮ 12,345';
+
+ expect($option->getAttributes()['additional_cost'])->toBe(12345);
+
+ $addonSlug = $addon->getSlugOptions();
+ $variantSlug = (new Fleetbase\Storefront\Models\ProductVariant())->getSlugOptions();
+ $locationSlug = (new Fleetbase\Storefront\Models\ProductStoreLocation())->getSlugOptions();
+
+ foreach ([$addonSlug, $variantSlug, $locationSlug] as $slug) {
+ expect($slug->generateSlugFrom)->toBe(['name'])
+ ->and($slug->slugField)->toBe('slug');
+ }
+});
+
+test('addon category option accessors accept arrays JSON and invalid values', function () {
+ $category = new Fleetbase\Storefront\Models\ProductAddonCategory();
+ $source = new Fleetbase\Storefront\Models\AddonCategory();
+ $source->forceFill(['name' => 'Sides']);
+ $category->setRelation('category', $source);
+
+ expect($category->getExcludedAddonsAttribute(['addon_a']))->toBe(['addon_a'])
+ ->and($category->getExcludedAddonsAttribute('["addon_b"]'))->toBe(['addon_b'])
+ ->and($category->getExcludedAddonsAttribute('invalid'))->toBe([])
+ ->and($category->name)->toBe('Sides');
+});
+
+test('creating specialized categories and notification channels applies model invariants', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('categories');
+ $schema->create('categories', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ });
+ $schema->dropIfExists('notification_channels');
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('app_key')->nullable();
+ });
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ Model::clearBootedModels();
+
+ try {
+ $addon = new Fleetbase\Storefront\Models\AddonCategory();
+ $catalog = new CatalogCategory();
+ $channel = new Fleetbase\Storefront\Models\NotificationChannel();
+ foreach ([$addon, $catalog, $channel] as $model) {
+ $fireCreating = new ReflectionMethod($model, 'fireModelEvent');
+ $fireCreating->invoke($model, 'creating', false);
+ }
+
+ expect($addon->for)->toBe('storefront_product_addon')
+ ->and($catalog->for)->toBe('storefront_catalog')
+ ->and($channel->app_key)->toStartWith('noty_channel_');
+ } finally {
+ Model::unsetEventDispatcher();
+ Model::clearBootedModels();
+ }
+});
+
+test('notification channels normalize provider configuration and expose gateway type', function () {
+ $channel = new Fleetbase\Storefront\Models\NotificationChannel();
+ $channel->setRawAttributes([
+ 'scheme' => 'apn',
+ 'config' => json_encode([
+ 'sandbox' => true,
+ 'private_key_content' => " private key\n",
+ 'enabled' => false,
+ ]),
+ ]);
+
+ expect($channel->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NotificationChannel())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NotificationChannel())->certificate())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\NotificationChannel())->owner())->toBeInstanceOf(MorphTo::class)
+ ->and($channel->is_apn_gateway)->toBeTrue()
+ ->and($channel->is_fcm_gateway)->toBeFalse()
+ ->and((array) $channel->config)->toBe([
+ 'private_key_content' => " private key\n",
+ 'enabled' => false,
+ 'sandbox' => true,
+ ]);
+
+ $channel->config = ['private_key_content' => " trimmed key \n", 'sandbox' => false];
+ $channel->scheme = 'fcm';
+ $channel->owner_type = 'storefront:store';
+
+ expect(json_decode($channel->getAttributes()['config'], true))->toBe([
+ 'private_key_content' => 'trimmed key',
+ 'sandbox' => false,
+ ])->and($channel->getAttributes()['owner_type'])->toBe(
+ Fleetbase\FleetOps\Support\Utils::getMutationType('storefront:store')
+ )->and($channel->is_apn_gateway)->toBeFalse()
+ ->and($channel->is_fcm_gateway)->toBeTrue();
+});
+
+test('addon categories create and update their addon rows deterministically', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('product_addons');
+ $schema->create('product_addons', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('created_by_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->string('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->integer('price')->default(0);
+ $table->integer('sale_price')->default(0);
+ $table->boolean('is_on_sale')->nullable();
+ $table->string('slug')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ session(['user' => 'user_test']);
+ $category = new Fleetbase\Storefront\Models\AddonCategory();
+ $category->forceFill(['uuid' => 'category_test']);
+ $category->setAddons([[
+ 'name' => 'Gift wrap',
+ 'description' => 'Wrapped carefully',
+ 'translations' => ['mn' => 'Бэлгийн боодол'],
+ 'price' => '$12.50',
+ 'sale_price' => '$10.00',
+ 'is_on_sale' => true,
+ ]]);
+
+ $created = Fleetbase\Storefront\Models\ProductAddon::query()->firstOrFail();
+ $addonUuid = (string) Illuminate\Support\Str::uuid();
+ Illuminate\Database\Capsule\Manager::connection('mysql')
+ ->table('product_addons')
+ ->where('id', $created->id)
+ ->update(['uuid' => $addonUuid]);
+
+ expect($created->category_uuid)->toBe('category_test')
+ ->and($created->created_by_uuid)->toBe('user_test')
+ ->and($created->price)->toBe(1250)
+ ->and($created->sale_price)->toBe(1000);
+
+ $category->setAddons([[
+ 'uuid' => $addonUuid,
+ 'name' => 'Premium gift wrap',
+ 'price' => 2000,
+ 'sale_price' => 1500,
+ 'is_on_sale' => false,
+ ]]);
+
+ $updated = Fleetbase\Storefront\Models\ProductAddon::query()
+ ->where('uuid', $addonUuid)
+ ->firstOrFail();
+
+ expect($updated->name)->toBe('Premium gift wrap')
+ ->and($updated->price)->toBe(2000)
+ ->and(Fleetbase\Storefront\Models\ProductAddon::query()->count())->toBe(1);
+});
+
+test('gateway model normalizes provider contracts configuration and fallbacks', function () {
+ $gateway = new Fleetbase\Storefront\Models\Gateway();
+ $gateway->setRawAttributes([
+ 'type' => 'Stripe',
+ 'config' => json_encode([
+ 'sandbox' => true,
+ 'secret_key' => 'secret',
+ 'enabled' => false,
+ ]),
+ ]);
+
+ expect($gateway->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Gateway())->company())->toBeInstanceOf(BelongsTo::class)
+ ->and((new Fleetbase\Storefront\Models\Gateway())->owner())->toBeInstanceOf(MorphTo::class)
+ ->and((new Fleetbase\Storefront\Models\Gateway())->logoFile())->toBeInstanceOf(BelongsTo::class)
+ ->and($gateway->logo_url)->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png')
+ ->and($gateway->is_stripe_gateway)->toBeTrue()
+ ->and($gateway->is_qpay_gateway)->toBeFalse()
+ ->and($gateway->isGateway('STRIPE'))->toBeTrue()
+ ->and((array) $gateway->config)->toBe([
+ 'secret_key' => 'secret',
+ 'enabled' => false,
+ 'sandbox' => true,
+ ]);
+
+ $gateway->owner_type = Fleetbase\Storefront\Models\Store::class;
+
+ expect($gateway->getAttributes()['owner_type'])->not->toBeEmpty();
+
+ $cash = Fleetbase\Storefront\Models\Gateway::cash(['sandbox' => true]);
+
+ expect($cash->public_id)->toBe('gateway_cash')
+ ->and($cash->code)->toBe('cash')
+ ->and($cash->sandbox)->toBeTrue();
+});
diff --git a/server/tests/Unit/Notifications/NotificationContractsTest.php b/server/tests/Unit/Notifications/NotificationContractsTest.php
new file mode 100644
index 00000000..d39f54b5
--- /dev/null
+++ b/server/tests/Unit/Notifications/NotificationContractsTest.php
@@ -0,0 +1,757 @@
+newInstanceWithoutConstructor();
+ $notification->order = $order;
+ $notification->storefront = $store;
+ $notification->sentAt = '2026-07-26 12:00:00';
+ $notification->notificationId = 'notification_contract';
+
+ foreach ($properties as $property => $value) {
+ $notification->{$property} = $value;
+ }
+
+ return $notification;
+}
+
+function notificationOrder(array $meta = []): Order
+{
+ $order = new Order();
+ $order->forceFill([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_public',
+ 'meta' => $meta,
+ ]);
+ $order->setRelation('customer', new class(['public_id' => 'contact_public', 'name' => 'Ada Buyer', 'email' => 'ada@example.test', 'phone' => '+15550100']) extends Illuminate\Database\Eloquent\Model {
+ protected $guarded = [];
+ });
+ $order->setRelation('company', new class(['public_id' => 'company_public', 'name' => 'Acme Logistics']) extends Illuminate\Database\Eloquent\Model {
+ protected $guarded = [];
+ });
+
+ return $order;
+}
+
+test('order lifecycle notifications expose stable mail and database contracts', function ($class, $subject, $body, $status, $arrayMessage) {
+ $order = notificationOrder();
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public', 'name' => 'Corner Store']);
+
+ $notification = notificationWithoutConstructor($class, $order, $store, [
+ 'subject' => $subject,
+ 'body' => $body,
+ 'status' => $status,
+ ]);
+ $notifiable = (object) ['public_id' => 'user_public'];
+ $mail = $notification->toMail($notifiable);
+ $payload = $notification->toArray($notifiable);
+
+ expect($mail)->toBeInstanceOf(MailMessage::class)
+ ->and($mail->subject)->toBe($subject)
+ ->and($mail->introLines)->toContain($body)
+ ->and($payload)->toMatchArray([
+ 'notifiable' => 'user_public',
+ 'notification_id' => 'notification_contract',
+ 'sent_at' => '2026-07-26 12:00:00',
+ 'subject' => $subject,
+ 'message' => $arrayMessage,
+ 'storefront' => 'Corner Store',
+ 'storefront_id' => 'store_public',
+ 'id' => 'contact_public',
+ 'email' => 'ada@example.test',
+ 'phone' => '+15550100',
+ 'companyId' => 'company_public',
+ 'company' => 'Acme Logistics',
+ ]);
+})->with([
+ 'accepted' => [
+ StorefrontOrderAccepted::class,
+ 'Order accepted',
+ 'Your order was accepted.',
+ 'order_accepted',
+ 'order_accepted',
+ ],
+ 'canceled' => [
+ StorefrontOrderCanceled::class,
+ 'Order canceled',
+ 'Your order was canceled.',
+ 'order_canceled',
+ 'Your order was canceled.',
+ ],
+ 'completed' => [
+ StorefrontOrderCompleted::class,
+ 'Order completed',
+ 'Your order was delivered.',
+ 'order_completed',
+ 'order_completed',
+ ],
+ 'driver assigned' => [
+ StorefrontOrderDriverAssigned::class,
+ 'Driver assigned',
+ 'A driver is heading to the store.',
+ 'order_driver_assigned',
+ 'order_driver_assigned',
+ ],
+ 'enroute' => [
+ StorefrontOrderEnroute::class,
+ 'Order enroute',
+ 'Your order is on the way.',
+ 'order_enroute',
+ 'order_enroute',
+ ],
+ 'nearby' => [
+ StorefrontOrderNearby::class,
+ 'Order nearby',
+ 'Your order is almost there.',
+ 'order_nearby',
+ 'order_nearby',
+ ],
+ 'preparing' => [
+ StorefrontOrderPreparing::class,
+ 'Order preparing',
+ 'Your order is being prepared.',
+ 'order_preparing',
+ 'order_preparing',
+ ],
+ 'ready for pickup' => [
+ StorefrontOrderReadyForPickup::class,
+ 'Order ready',
+ 'Your order is ready for pickup.',
+ 'order_ready',
+ 'order_ready',
+ ],
+]);
+
+test('order lifecycle notifications build their runtime messages and provider payloads', function ($class, $expectedStatus) {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('notification_channels');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->string('company_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name');
+ $table->string('currency')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('owner_uuid');
+ $table->string('scheme');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Corner Store',
+ 'currency' => 'USD',
+ 'options' => '{}',
+ ]);
+
+ $order = notificationOrder(['storefront_id' => 'store_public']);
+ if ($class === StorefrontOrderDriverAssigned::class) {
+ $driver = new Fleetbase\FleetOps\Models\Driver();
+ $driver->forceFill(['name' => 'Taylor Driver']);
+ $order->setRelation('driverAssigned', $driver);
+ }
+
+ $notification = $class === StorefrontOrderNearby::class
+ ? new $class($order, 125, 300)
+ : new $class($order);
+ $notifiable = (object) ['public_id' => 'user_public'];
+
+ expect($notification->storefront->public_id)->toBe('store_public')
+ ->and($notification->status)->toBe($expectedStatus)
+ ->and($notification->subject)->not->toBeEmpty()
+ ->and($notification->body)->not->toBeEmpty()
+ ->and($notification->via($notifiable))->toBe(['mail', 'database'])
+ ->and($notification->toFcm($notifiable))->toBeInstanceOf(FcmMessage::class)
+ ->and($notification->toApn($notifiable))->toBeInstanceOf(ApnMessage::class);
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->insert([
+ ['owner_uuid' => 'store_uuid', 'scheme' => 'apn'],
+ ['owner_uuid' => 'store_uuid', 'scheme' => 'fcm'],
+ ]);
+
+ expect($notification->via($notifiable))->toBe([
+ 'mail',
+ 'database',
+ NotificationChannels\Apn\ApnChannel::class,
+ NotificationChannels\Fcm\FcmChannel::class,
+ ]);
+})->with([
+ 'accepted' => [StorefrontOrderAccepted::class, 'order_accepted'],
+ 'canceled' => [StorefrontOrderCanceled::class, 'order_canceled'],
+ 'completed' => [StorefrontOrderCompleted::class, 'order_completed'],
+ 'driver assigned' => [StorefrontOrderDriverAssigned::class, 'order_driver_assigned'],
+ 'enroute' => [StorefrontOrderEnroute::class, 'order_enroute'],
+ 'nearby' => [StorefrontOrderNearby::class, 'order_nearby'],
+ 'preparing' => [StorefrontOrderPreparing::class, 'order_preparing'],
+ 'ready for pickup'=> [StorefrontOrderReadyForPickup::class, 'order_ready'],
+]);
+
+test('created-order notification renders pickup messages without delivery-only charges', function () {
+ $order = notificationOrder([
+ 'is_pickup' => true,
+ 'subtotal' => 2500,
+ 'delivery_fee' => 500,
+ 'delivery_tip' => 200,
+ 'tip' => 100,
+ 'total' => 2600,
+ 'currency' => 'USD',
+ ]);
+ $order->setRelation('payload', (object) [
+ 'entities' => collect([(object) ['name' => 'Coffee'], (object) ['name' => 'Cake']]),
+ 'dropoff' => (object) ['address' => '1 Market Street'],
+ ]);
+ $store = new Store(['name' => 'Corner Store']);
+ $notification = notificationWithoutConstructor(StorefrontOrderCreated::class, $order, $store);
+
+ expect($notification->via(null))->toBe(['mail', TwilioChannel::class]);
+
+ $sms = $notification->toTwilio(null);
+ $mail = $notification->toMail(null);
+
+ expect($sms)->toBeInstanceOf(TwilioSmsMessage::class)
+ ->and($sms->content)->toContain('A new pickup order was just created!')
+ ->and($sms->content)->toContain('Items: Coffee,Cake')
+ ->and($sms->content)->toContain('Tip:')
+ ->and($sms->content)->not->toContain('Delivery Fee:')
+ ->and($mail)->toBeInstanceOf(MailMessage::class)
+ ->and($mail->subject)->toContain('Corner Store')
+ ->and(implode("\n", $mail->introLines))->toContain('A new pickup order was just created!')
+ ->not->toContain('Delivery Fee:')
+ ->and($notification->toArray(null))->toMatchArray([
+ 'uuid' => 'order_uuid',
+ 'public_id' => 'order_public',
+ ]);
+});
+
+test('created-order notification includes delivery address fee and optional delivery tip', function () {
+ $order = notificationOrder([
+ 'is_pickup' => false,
+ 'subtotal' => 2500,
+ 'delivery_fee' => 500,
+ 'delivery_tip' => 200,
+ 'tip' => null,
+ 'total' => 3200,
+ 'currency' => 'USD',
+ ]);
+ $order->setRelation('payload', (object) [
+ 'entities' => collect([(object) ['name' => 'Coffee']]),
+ 'dropoff' => (object) ['address' => '1 Market Street'],
+ ]);
+ $notification = notificationWithoutConstructor(
+ StorefrontOrderCreated::class,
+ $order,
+ new Store(['name' => 'Corner Store'])
+ );
+
+ $sms = $notification->toTwilio(null);
+ $mailLines = implode("\n", $notification->toMail(null)->introLines);
+
+ expect($sms->content)->toContain('A new delivery order was just created!')
+ ->and($sms->content)->toContain('Address: 1 Market Street')
+ ->and($sms->content)->toContain('Delivery Fee:')
+ ->and($sms->content)->toContain('Delivery Tip:')
+ ->and($mailLines)->toContain('Address: 1 Market Street')
+ ->and($mailLines)->toContain('Delivery Fee:')
+ ->and($mailLines)->toContain('Delivery Tip:');
+});
+
+test('created-order notification resolves its storefront from order metadata', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->string('name');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'name' => 'Corner Store',
+ ]);
+
+ $notification = new StorefrontOrderCreated(notificationOrder(['storefront_id' => 'store_public']));
+
+ expect($notification->storefront->public_id)->toBe('store_public')
+ ->and($notification->notificationId)->toStartWith('notification_')
+ ->and($notification->sentAt)->not->toBeEmpty();
+});
+
+test('promotional notifications expose their persisted payload contract', function () {
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public', 'name' => 'Corner Store']);
+
+ $notification = new PromotionalPushNotification('Weekend sale', 'Save twenty percent', $store);
+ $payload = $notification->toArray(null);
+
+ expect($payload)->toMatchArray([
+ 'title' => 'Weekend sale',
+ 'body' => 'Save twenty percent',
+ 'store' => 'store_uuid',
+ 'store_id' => 'store_public',
+ 'type' => 'promotional',
+ ])->and($payload['sent_at'])->not->toBeEmpty()
+ ->and($payload['notification_id'])->toStartWith('notification_');
+});
+
+test('promotional notifications build configured APN and FCM provider messages', function () {
+ config(['firebase.projects.app' => [
+ 'credentials' => ['private_key' => 'default-key'],
+ 'database' => ['url' => 'https://default.example.test'],
+ ]]);
+
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+
+ $channel = new NotificationChannel();
+ $channel->forceFill(['app_key' => 'store-app']);
+ $channel->config = [
+ 'firebase_credentials_json' => 'channel-private-key',
+ 'firebase_database_url' => 'https://channel.example.test',
+ ];
+
+ TestablePromotionalPushNotification::$apnClient = (new ReflectionClass(Pushok\Client::class))->newInstanceWithoutConstructor();
+ TestablePromotionalPushNotification::$fcmChannel = $channel;
+ TestablePromotionalPushNotification::$fcmClient = (new ReflectionClass(Kreait\Firebase\Messaging::class))->newInstanceWithoutConstructor();
+
+ $notification = new TestablePromotionalPushNotification('Weekend sale', 'Save now', $store);
+
+ expect($notification->toApn(null))->toBeInstanceOf(ApnMessage::class)
+ ->and($notification->toFcm(null))->toBeInstanceOf(FcmMessage::class)
+ ->and(data_get(config('firebase.projects.store-app'), 'credentials.private_key'))->toBe('channel-private-key');
+});
+
+test('promotional notification provider resolvers honor the persisted channel configuration', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('notification_channels');
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('owner_uuid');
+ $table->string('app_key');
+ $table->string('scheme');
+ $table->text('config')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+ $notification = new PromotionalPushNotification('Weekend sale', 'Save now', $store);
+
+ $apnResolver = new ReflectionMethod($notification, 'getApnClient');
+ $fcmResolver = new ReflectionMethod($notification, 'getFcmNotificationChannel');
+ $clientResolver = new ReflectionMethod($notification, 'getFcmClient');
+
+ expect($apnResolver->invoke($notification))->toBeNull()
+ ->and($fcmResolver->invoke($notification))->toBeNull();
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->insert([
+ 'uuid' => 'channel_uuid',
+ 'owner_uuid' => 'store_uuid',
+ 'app_key' => 'store-app',
+ 'scheme' => 'fcm',
+ 'config' => '{}',
+ ]);
+
+ $channel = $fcmResolver->invoke($notification);
+
+ expect($channel?->app_key)->toBe('store-app');
+
+ try {
+ $clientResolver->invoke($notification, $channel);
+ } catch (Throwable $exception) {
+ expect($exception)->toBeInstanceOf(Throwable::class);
+ }
+});
+
+test('push notification messages retain payloads when provider channels are not configured', function () {
+ $order = notificationOrder();
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+
+ $double = new class extends PushNotification {
+ public static Store $store;
+ public static ?string $requestedScheme = null;
+
+ public static function getStorefrontFromOrder(Order $order): Network|Store|null
+ {
+ return static::$store;
+ }
+
+ public static function getApnClient(Network|Store $storefront, ?Order $order = null): ?Pushok\Client
+ {
+ return null;
+ }
+
+ public static function getNotificationChannel(string $scheme, Network|Store $storefront, ?Order $order = null): ?NotificationChannel
+ {
+ static::$requestedScheme = $scheme;
+
+ return null;
+ }
+ };
+ $double::$store = $store;
+
+ $apn = $double::createApnMessage($order, 'Order ready', 'Collect your order', 'pickup_ready');
+ $fcm = $double::createFcmMessage($order, 'Order ready', 'Collect your order', 'pickup_ready');
+
+ expect($apn)->toBeInstanceOf(ApnMessage::class)
+ ->and($fcm)->toBeInstanceOf(FcmMessage::class)
+ ->and($double::$requestedScheme)->toBe('fcm');
+});
+
+test('configured FCM messages include order routing metadata and the isolated provider client', function () {
+ $order = notificationOrder();
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+ $channel = new NotificationChannel();
+ $channel->forceFill(['app_key' => 'store-app']);
+ $channel->config = [
+ 'firebase_credentials_json' => 'channel-private-key',
+ 'firebase_database_url' => 'https://channel.example.test',
+ ];
+ $client = (new ReflectionClass(Kreait\Firebase\Messaging::class))->newInstanceWithoutConstructor();
+
+ $double = new class extends PushNotification {
+ public static Store $store;
+ public static NotificationChannel $channel;
+ public static Kreait\Firebase\Contract\Messaging $client;
+
+ public static function getStorefrontFromOrder(Order $order): Network|Store|null
+ {
+ return static::$store;
+ }
+
+ public static function getNotificationChannel(string $scheme, Network|Store $storefront, ?Order $order = null): ?NotificationChannel
+ {
+ return static::$channel;
+ }
+
+ protected static function getFcmClient(NotificationChannel $notificationChannel)
+ {
+ return static::$client;
+ }
+ };
+ $double::$store = $store;
+ $double::$channel = $channel;
+ $double::$client = $client;
+ config(['firebase.projects.app' => [
+ 'credentials' => ['private_key' => 'default-key'],
+ 'database' => ['url' => 'https://default.example.test'],
+ ]]);
+
+ $message = $double::createFcmMessage($order, 'Order ready', 'Collect your order', 'pickup_ready');
+ $payload = $message->toArray();
+
+ expect($message->client)->toBe($client)
+ ->and($message->notification?->title)->toBe('Order ready')
+ ->and($message->notification?->body)->toBe('Collect your order')
+ ->and($message->data)->toBe([
+ 'order' => $order->uuid,
+ 'id' => $order->public_id,
+ 'type' => 'pickup_ready',
+ ])
+ ->and(data_get($payload, 'android.notification.sound'))->toBe('default')
+ ->and(data_get($payload, 'apns.payload.aps.sound'))->toBe('default');
+});
+
+test('push notification creates the configured firebase project messaging client', function () {
+ $privateKey = openssl_pkey_new(['private_key_bits' => 2048]);
+ openssl_pkey_export($privateKey, $privateKeyContent);
+ config(['firebase.projects.real-app' => [
+ 'credentials' => [
+ 'type' => 'service_account',
+ 'project_id' => 'storefront-tests',
+ 'private_key_id' => 'key-id',
+ 'private_key' => $privateKeyContent,
+ 'client_email' => 'firebase-admin@example.test',
+ 'client_id' => '123456789',
+ 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth',
+ 'token_uri' => 'https://oauth2.googleapis.com/token',
+ 'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs',
+ 'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509/firebase-admin',
+ ],
+ ]]);
+ Illuminate\Container\Container::getInstance()->instance('config', new class {
+ public function get(string $key)
+ {
+ return config($key);
+ }
+ });
+ $channel = new NotificationChannel();
+ $channel->forceFill(['app_key' => 'real-app']);
+ $method = new ReflectionMethod(PushNotification::class, 'getFcmClient');
+
+ $client = $method->invoke(null, $channel);
+
+ expect($client)->toBeInstanceOf(Kreait\Firebase\Contract\Messaging::class);
+});
+
+test('push notification configures isolated firebase projects from channel credentials', function () {
+ config(['firebase.projects.app' => [
+ 'credentials' => ['private_key' => 'default-key', 'client_email' => 'firebase@example.test'],
+ 'database' => ['url' => 'https://default.example.test'],
+ ]]);
+
+ $channel = new NotificationChannel();
+ $channel->forceFill(['app_key' => 'store-app']);
+ $channel->config = [
+ 'firebase_credentials_json' => 'channel-private-key',
+ 'firebase_database_url' => 'https://channel.example.test',
+ ];
+
+ $configured = PushNotification::configureFcm($channel);
+
+ expect(data_get($configured, 'credentials.private_key'))->toBe('channel-private-key')
+ ->and(data_get($configured, 'credentials.client_email'))->toBe('firebase@example.test')
+ ->and(data_get($configured, 'database.url'))->toBe('https://channel.example.test')
+ ->and(config('firebase.projects.store-app'))->toBe($configured);
+});
+
+test('push notification channel lookup honors an order channel override before storefront defaults', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('notification_channels');
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('owner_uuid');
+ $table->string('owner_type')->nullable();
+ $table->string('app_key');
+ $table->string('scheme');
+ $table->text('config')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->insert([
+ [
+ 'uuid' => 'channel_default',
+ 'owner_uuid' => 'store_uuid',
+ 'app_key' => 'default-app',
+ 'scheme' => 'fcm',
+ ],
+ [
+ 'uuid' => 'channel_override',
+ 'owner_uuid' => 'store_uuid',
+ 'app_key' => 'override-app',
+ 'scheme' => 'fcm',
+ ],
+ ]);
+
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid']);
+
+ $default = PushNotification::getNotificationChannel('fcm', $store);
+ $order = notificationOrder(['storefront_notification_channel' => 'override-app']);
+ $override = PushNotification::getNotificationChannel('fcm', $store, $order);
+
+ expect($default?->app_key)->toBe('default-app')
+ ->and($override?->app_key)->toBe('override-app')
+ ->and(PushNotification::getNotificationChannel('apn', $store))->toBeNull();
+});
+
+test('push notification resolves APN credentials into a production-aware provider client', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('notification_channels');
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('owner_uuid');
+ $table->string('owner_type')->nullable();
+ $table->string('app_key');
+ $table->string('scheme');
+ $table->text('config')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $privateKey = openssl_pkey_new([
+ 'private_key_type' => OPENSSL_KEYTYPE_EC,
+ 'curve_name' => 'secp521r1',
+ ]);
+ openssl_pkey_export($privateKey, $privateKeyContent);
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->insert([
+ 'uuid' => 'channel_apn',
+ 'owner_uuid' => 'store_uuid',
+ 'app_key' => 'store-app',
+ 'scheme' => 'apn',
+ 'config' => json_encode([
+ 'key_id' => 'KEY123',
+ 'team_id' => 'TEAM123',
+ 'app_bundle_id' => 'com.example.storefront',
+ 'private_key_content' => $privateKeyContent,
+ 'production' => false,
+ ]),
+ ]);
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid']);
+
+ $client = PushNotification::getApnClient($store);
+
+ expect($client)->toBeInstanceOf(Pushok\Client::class);
+});
+
+test('push notification resolves the storefront referenced by order metadata', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('stores');
+ $schema->create('stores', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('key')->nullable();
+ $table->string('name')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('order_config_uuid')->nullable();
+ $table->text('description')->nullable();
+ $table->text('translations')->nullable();
+ $table->string('website')->nullable();
+ $table->string('facebook')->nullable();
+ $table->string('instagram')->nullable();
+ $table->string('twitter')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->text('tags')->nullable();
+ $table->string('currency')->nullable();
+ $table->string('timezone')->nullable();
+ $table->string('pod_method')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'key' => 'store_key',
+ 'name' => 'Test store',
+ ]);
+
+ $storefront = PushNotification::getStorefrontFromOrder(
+ notificationOrder(['storefront_id' => 'store_public'])
+ );
+
+ expect($storefront)->toBeInstanceOf(Store::class)
+ ->and($storefront->uuid)->toBe('store_uuid');
+});
+
+test('configured APN messages include order routing metadata and provider client', function () {
+ $order = notificationOrder();
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+ $client = (new ReflectionClass(Pushok\Client::class))->newInstanceWithoutConstructor();
+
+ $double = new class extends PushNotification {
+ public static Store $store;
+ public static Pushok\Client $client;
+
+ public static function getStorefrontFromOrder(Order $order): Network|Store|null
+ {
+ return static::$store;
+ }
+
+ public static function getApnClient(Network|Store $storefront, ?Order $order = null): ?Pushok\Client
+ {
+ return static::$client;
+ }
+ };
+ $double::$store = $store;
+ $double::$client = $client;
+
+ $message = $double::createApnMessage($order, 'Order ready', 'Collect it now', 'pickup_ready');
+
+ expect($message)->toBeInstanceOf(ApnMessage::class);
+});
+
+test('promotional notifications select only configured provider channels and fail closed without them', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('notification_channels');
+ $schema->create('notification_channels', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('owner_uuid');
+ $table->string('owner_type')->nullable();
+ $table->string('app_key');
+ $table->string('scheme');
+ $table->text('config')->nullable();
+ $table->text('options')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->insert([
+ ['owner_uuid' => 'store_uuid', 'app_key' => 'apn-app', 'scheme' => 'apn'],
+ ['owner_uuid' => 'store_uuid', 'app_key' => 'fcm-app', 'scheme' => 'fcm'],
+ ]);
+
+ $store = new Store();
+ $store->forceFill(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+ $notification = new PromotionalPushNotification('Weekend sale', 'Save now', $store);
+
+ expect($notification->via(null))->toBe([
+ NotificationChannels\Apn\ApnChannel::class,
+ NotificationChannels\Fcm\FcmChannel::class,
+ ]);
+
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('notification_channels')->delete();
+
+ expect($notification->via(null))->toBe([])
+ ->and($notification->toApn(null))->toBeNull()
+ ->and($notification->toFcm(null))->toBeNull();
+});
diff --git a/server/tests/Unit/Observers/CompanyObserverTest.php b/server/tests/Unit/Observers/CompanyObserverTest.php
new file mode 100644
index 00000000..6b5ac750
--- /dev/null
+++ b/server/tests/Unit/Observers/CompanyObserverTest.php
@@ -0,0 +1,77 @@
+connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('order_configs');
+ $schema->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->string('author_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->string('icon_uuid')->nullable();
+ $table->string('name');
+ $table->string('namespace');
+ $table->text('description')->nullable();
+ $table->string('key');
+ $table->string('status');
+ $table->string('version');
+ $table->boolean('core_service')->default(false);
+ $table->text('tags')->nullable();
+ $table->text('flow')->nullable();
+ $table->text('entities')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $company = new Company();
+ $company->forceFill(['uuid' => 'company_uuid']);
+
+ (new CompanyObserver())->created($company);
+
+ $config = $connection->table('order_configs')->first();
+
+ expect($config)->not->toBeNull()
+ ->and($config->company_uuid)->toBe('company_uuid')
+ ->and($config->key)->toBe('storefront')
+ ->and($config->namespace)->toBe('system:order-config:storefront')
+ ->and($config->core_service)->toBe(1);
+});
+
+test('order creation applies the company storefront configuration when none is selected', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('order_configs');
+ $schema->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('company_uuid');
+ $table->string('name');
+ $table->string('namespace');
+ $table->string('key');
+ $table->string('status')->nullable();
+ $table->string('version')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('order_configs')->insert([
+ 'uuid' => 'order_config_uuid',
+ 'company_uuid' => 'observer_company_uuid',
+ 'name' => 'Storefront',
+ 'namespace' => 'system:order-config:storefront',
+ 'key' => 'storefront',
+ ]);
+ session(['company' => 'observer_company_uuid']);
+ $order = new Order();
+
+ (new OrderObserver())->creating($order);
+
+ expect($order->order_config_uuid)->toBe('order_config_uuid');
+});
diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php
new file mode 100644
index 00000000..3f154d48
--- /dev/null
+++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php
@@ -0,0 +1,116 @@
+registered[] = $provider;
+
+ return $provider;
+ }
+ };
+ $provider = new StorefrontServiceProvider($app);
+
+ $provider->register();
+
+ expect($app->registered)->toBe([
+ CoreServiceProvider::class,
+ FleetOpsServiceProvider::class,
+ ]);
+});
+
+test('storefront provider boot wires commands schedules observers middleware and package files', function () {
+ $provider = new class(new Fleetbase\TestSupport\ApplicationContainer()) extends StorefrontServiceProvider {
+ public array $calls = [];
+
+ public function registerCommands(): void
+ {
+ $this->calls[] = 'commands';
+ }
+
+ public function scheduleCommands(?callable $callback = null): void
+ {
+ $this->calls[] = 'schedule';
+ $schedule = new class {
+ public array $commands = [];
+
+ public function command(string $command): self
+ {
+ $this->commands[] = $command;
+
+ return $this;
+ }
+
+ public function everyMinute(): self
+ {
+ return $this;
+ }
+
+ public function daily(): self
+ {
+ return $this;
+ }
+
+ public function storeOutputInDb(): self
+ {
+ return $this;
+ }
+ };
+ $callback($schedule);
+ $this->calls = [...$this->calls, ...$schedule->commands];
+ }
+
+ public function registerObservers(): void
+ {
+ $this->calls[] = 'observers';
+ }
+
+ public function registerMiddleware(): void
+ {
+ $this->calls[] = 'middleware';
+ }
+
+ public function registerExpansionsFrom($from = null, $namespace = null): void
+ {
+ $this->calls[] = 'expansions';
+ }
+
+ protected function loadRoutesFrom($path)
+ {
+ $this->calls[] = 'routes';
+ }
+
+ protected function loadMigrationsFrom($paths)
+ {
+ $this->calls[] = 'migrations';
+ }
+
+ protected function mergeConfigFrom($path, $key)
+ {
+ $this->calls[] = $key;
+ }
+ };
+
+ $provider->boot();
+
+ expect($provider->calls)->toBe([
+ 'commands',
+ 'schedule',
+ 'storefront:notify-order-nearby',
+ 'storefront:purge-carts',
+ 'observers',
+ 'middleware',
+ 'expansions',
+ 'routes',
+ 'migrations',
+ 'database.connections',
+ 'storefront',
+ 'storefront.api',
+ ]);
+});
diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php
new file mode 100644
index 00000000..8b8f4452
--- /dev/null
+++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php
@@ -0,0 +1,100 @@
+record('GET', $uri, $action);
+ }
+
+ public function post(string $uri, mixed $action): self
+ {
+ return $this->record('POST', $uri, $action);
+ }
+
+ public function put(string $uri, mixed $action): self
+ {
+ return $this->record('PUT', $uri, $action);
+ }
+
+ public function patch(string $uri, mixed $action): self
+ {
+ return $this->record('PATCH', $uri, $action);
+ }
+
+ public function delete(string $uri, mixed $action): self
+ {
+ return $this->record('DELETE', $uri, $action);
+ }
+
+ public function match(array $methods, string $uri, mixed $action): self
+ {
+ foreach ($methods as $method) {
+ $this->record(strtoupper($method), $uri, $action);
+ }
+
+ return $this;
+ }
+
+ public function fleetbaseRoutes(string $resource, ?Closure $callback = null): self
+ {
+ $this->routes[] = ['FLEETBASE', $resource, null];
+
+ if ($callback) {
+ $callback($this, fn (string $action): string => $resource . ':' . $action);
+ }
+
+ return $this;
+ }
+
+ private function record(string $method, string $uri, mixed $action): self
+ {
+ $this->routes[] = [$method, $uri, $action];
+
+ return $this;
+ }
+}
+
+test('storefront route file registers public consumable and internal API contracts', function () {
+ $router = new StorefrontRouteRecorder();
+ app()->instance('router', $router);
+ Route::clearResolvedInstance('router');
+
+ require dirname(__DIR__, 3) . '/src/routes.php';
+
+ expect($router->routes)->toContain(
+ ['GET', 'about', 'StoreController@about'],
+ ['POST', '/', 'ProductController@create'],
+ ['POST', 'receipt', 'OrderController@getReceipt'],
+ ['POST', 'send-push-notification', 'ActionController@sendPushNotification'],
+ ['FLEETBASE', 'orders', null],
+ ['FLEETBASE', 'products', null],
+ ['GET', '/', 'MetricsController@all'],
+ )->and(count($router->routes))->toBeGreaterThan(50);
+});
diff --git a/server/tests/Unit/Rules/RuleContractsTest.php b/server/tests/Unit/Rules/RuleContractsTest.php
new file mode 100644
index 00000000..d9e3dec2
--- /dev/null
+++ b/server/tests/Unit/Rules/RuleContractsTest.php
@@ -0,0 +1,169 @@
+passes('gateway', 'cash'))->toBeTrue()
+ ->and($rule->message())->toBe('No gateway by code provided exists.');
+});
+
+test('location validation accepts coordinate arrays and objects and rejects malformed values', function () {
+ $rule = new IsValidLocation();
+
+ expect($rule->passes('origin', [
+ 'latitude' => 1.3521,
+ 'longitude' => 103.8198,
+ ]))->toBeTrue()
+ ->and($rule->passes('origin', (object) [
+ 'coordinates' => [
+ 'latitude' => 1.3521,
+ 'longitude' => 103.8198,
+ ],
+ ]))->toBeTrue()
+ ->and($rule->passes('origin', null))->toBeFalse()
+ ->and($rule->message())->toBe('Invalid :attribute.');
+});
+
+test('validation rules expose stable client-facing failure messages', function () {
+ expect((new CartExists())->message())->toBe('Cart session does not exists.')
+ ->and((new CustomerExists())->message())->toBe('No customer found.');
+});
+
+test('cart validation accepts either the public id or browser identifier and rejects unknown carts', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('carts');
+ $schema->create('carts', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('public_id')->nullable();
+ $table->string('unique_identifier')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('carts')->insert([
+ 'public_id' => 'cart_public',
+ 'unique_identifier' => 'browser_session',
+ ]);
+
+ $rule = new CartExists();
+
+ expect($rule->passes('cart', 'cart_public'))->toBeTrue()
+ ->and($rule->passes('cart', 'browser_session'))->toBeTrue()
+ ->and($rule->passes('cart', 'cart_missing'))->toBeFalse();
+});
+
+test('customer and gateway validation resolve persisted public contracts', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ $schema->dropIfExists('contacts');
+ $schema->dropIfExists('gateways');
+ $schema->create('contacts', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('public_id');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('gateways', function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('code');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('contacts')->insert([
+ 'public_id' => 'contact_123',
+ ]);
+ Illuminate\Database\Capsule\Manager::connection('mysql')->table('gateways')->insert([
+ 'code' => 'stripe',
+ ]);
+
+ expect((new CustomerExists())->passes('customer', 'customer_123'))->toBeTrue()
+ ->and((new CustomerExists())->passes('customer', 'customer_missing'))->toBeFalse()
+ ->and((new GatewayExists())->passes('gateway', 'stripe'))->toBeTrue()
+ ->and((new GatewayExists())->passes('gateway', 'missing'))->toBeFalse();
+});
+
+test('location validation resolves every supported persisted location identifier', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+
+ foreach (['places', 'store_locations', 'vehicles', 'food_trucks'] as $tableName) {
+ $schema->dropIfExists($tableName);
+ $schema->create($tableName, function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('public_id');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ $connection = Illuminate\Database\Capsule\Manager::connection('mysql');
+ $connection->table('places')->insert(['public_id' => 'place_123']);
+ $connection->table('store_locations')->insert(['public_id' => 'store_location_123']);
+ $connection->table('vehicles')->insert(['public_id' => 'vehicle_123']);
+ $connection->table('food_trucks')->insert(['public_id' => 'food_truck_123']);
+
+ $rule = new IsValidLocation();
+
+ expect($rule->passes('origin', 'place_123'))->toBeTrue()
+ ->and($rule->passes('origin', 'store_location_123'))->toBeTrue()
+ ->and($rule->passes('origin', 'vehicle_123'))->toBeTrue()
+ ->and($rule->passes('origin', 'food_truck_123'))->toBeTrue()
+ ->and($rule->passes('origin', 'place_missing'))->toBeFalse()
+ ->and($rule->passes('origin', 'unsupported_123'))->toBeFalse();
+});
+
+test('stripe payment method validation fails safely when customer metadata is incomplete', function () {
+ $customer = new Customer();
+ $customer->forceFill(['meta' => []]);
+
+ expect(StripeUtils::isCustomerPaymentMethodValid($customer))->toBeFalse();
+
+ $customer->forceFill(['meta' => ['stripe_id' => 'cus_123']]);
+
+ expect(StripeUtils::isCustomerPaymentMethodValid($customer))->toBeFalse();
+});
+
+test('stripe payment method validation verifies ownership and contains provider failures', function () {
+ Stripe\Stripe::setApiKey('sk_test_storefront');
+ $customer = new Customer();
+ $customer->forceFill(['meta' => [
+ 'stripe_id' => 'cus_expected',
+ 'stripe_payment_method_id' => 'pm_saved',
+ ]]);
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ return [json_encode([
+ 'id' => 'pm_saved',
+ 'object' => 'payment_method',
+ 'customer' => 'cus_expected',
+ 'type' => 'card',
+ ]), 200, []];
+ }
+ });
+
+ expect(StripeUtils::isCustomerPaymentMethodValid($customer))->toBeTrue();
+
+ $customer->forceFill(['meta' => [
+ 'stripe_id' => 'cus_other',
+ 'stripe_payment_method_id' => 'pm_saved',
+ ]]);
+
+ expect(StripeUtils::isCustomerPaymentMethodValid($customer))->toBeFalse();
+
+ Stripe\ApiRequestor::setHttpClient(new class implements Stripe\HttpClient\ClientInterface {
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null)
+ {
+ throw new RuntimeException('Stripe unavailable');
+ }
+ });
+
+ expect(StripeUtils::isCustomerPaymentMethodValid($customer))->toBeFalse();
+
+ Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient());
+});
diff --git a/server/tests/Unit/Support/MetricsTest.php b/server/tests/Unit/Support/MetricsTest.php
new file mode 100644
index 00000000..f52c4341
--- /dev/null
+++ b/server/tests/Unit/Support/MetricsTest.php
@@ -0,0 +1,142 @@
+forceFill(['uuid' => 'company_uuid']);
+ $start = new DateTime('2026-01-01 00:00:00');
+ $end = new DateTime('2026-01-31 23:59:59');
+
+ $metrics = Metrics::forCompany($company, $start, $end);
+
+ $startProperty = new ReflectionProperty(Metrics::class, 'start');
+ $endProperty = new ReflectionProperty(Metrics::class, 'end');
+ $companyProperty = new ReflectionProperty(Metrics::class, 'company');
+
+ expect($metrics)->toBeInstanceOf(Metrics::class)
+ ->and($startProperty->getValue($metrics))->toBe($start)
+ ->and($endProperty->getValue($metrics))->toBe($end)
+ ->and($companyProperty->getValue($metrics))->toBe($company)
+ ->and($metrics->get())->toBe([]);
+});
+
+test('metrics builder supplies broad defaults and remains fluently configurable', function () {
+ Carbon::setTestNow('2026-07-26 12:00:00');
+
+ $company = new Company();
+ $company->forceFill(['uuid' => 'company_uuid']);
+ $metrics = Metrics::new($company);
+
+ $start = new DateTime('2026-07-01');
+ $end = new DateTime('2026-07-31');
+
+ expect($metrics->start($start))->toBe($metrics)
+ ->and($metrics->end($end))->toBe($metrics)
+ ->and($metrics->between($start, $end))->toBe($metrics)
+ ->and($metrics->with(['unknown_metric', 'also unknown']))->toBe($metrics)
+ ->and($metrics->get())->toBe([]);
+
+ Carbon::setTestNow();
+});
+
+test('metrics stores nested and batch values deterministically', function () {
+ $company = new Company();
+ $company->forceFill(['uuid' => 'company_uuid']);
+ $metrics = Metrics::forCompany($company);
+ $set = new ReflectionMethod(Metrics::class, 'set');
+
+ expect($set->invoke($metrics, 'orders.completed', 12))->toBe($metrics)
+ ->and($set->invoke($metrics, [
+ 'orders.canceled' => 3,
+ 'products.total' => 25,
+ ]))->toBe($metrics)
+ ->and($metrics->get())->toBe([
+ 'orders' => [
+ 'completed' => 12,
+ 'canceled' => 3,
+ ],
+ 'products' => [
+ 'total' => 25,
+ ],
+ ]);
+});
+
+test('metrics count storefront inventory and order states within the reporting window', function () {
+ $schema = Illuminate\Database\Capsule\Manager::schema('mysql');
+ foreach (['products', 'stores', 'networks', 'orders'] as $table) {
+ $schema->dropIfExists($table);
+ $schema->create($table, function (Illuminate\Database\Schema\Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->string('type')->nullable();
+ $table->string('status')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ $connection = Illuminate\Database\Capsule\Manager::connection('mysql');
+ foreach (['products', 'stores', 'networks'] as $table) {
+ $connection->table($table)->insert([
+ ['public_id' => $table . '_included', 'company_uuid' => 'company_uuid'],
+ ['public_id' => $table . '_other', 'company_uuid' => 'other_company'],
+ ]);
+ }
+ $connection->table('orders')->insert([
+ ['public_id' => 'order_active', 'company_uuid' => 'company_uuid', 'type' => 'storefront', 'status' => 'dispatched', 'created_at' => '2026-07-15 12:00:00'],
+ ['public_id' => 'order_complete', 'company_uuid' => 'company_uuid', 'type' => 'storefront', 'status' => 'completed', 'created_at' => '2026-07-16 12:00:00'],
+ ['public_id' => 'order_canceled', 'company_uuid' => 'company_uuid', 'type' => 'storefront', 'status' => 'canceled', 'created_at' => '2026-07-17 12:00:00'],
+ ['public_id' => 'order_created', 'company_uuid' => 'company_uuid', 'type' => 'storefront', 'status' => 'created', 'created_at' => '2026-07-18 12:00:00'],
+ ['public_id' => 'order_old', 'company_uuid' => 'company_uuid', 'type' => 'storefront', 'status' => 'completed', 'created_at' => '2025-07-16 12:00:00'],
+ ['public_id' => 'order_other_type', 'company_uuid' => 'company_uuid', 'type' => 'fleet-ops', 'status' => 'completed', 'created_at' => '2026-07-16 12:00:00'],
+ ]);
+
+ $company = new Company();
+ $company->forceFill(['uuid' => 'company_uuid']);
+ $metrics = Metrics::forCompany(
+ $company,
+ new DateTime('2026-07-01 00:00:00'),
+ new DateTime('2026-07-31 23:59:59')
+ );
+ $onlyIncluded = function ($query) {
+ $query->where('public_id', 'like', '%_included');
+ };
+ $onlyExpectedOrder = function ($query) {
+ $query->where('public_id', 'not like', '%_excluded');
+ };
+
+ $metrics
+ ->totalProducts($onlyIncluded)
+ ->totalStores($onlyIncluded)
+ ->totalNetworks($onlyIncluded)
+ ->ordersInProgress($onlyExpectedOrder)
+ ->ordersCompleted($onlyExpectedOrder)
+ ->ordersCanceled($onlyExpectedOrder);
+
+ expect($metrics->get())->toBe([
+ 'total_products' => 1,
+ 'total_stores' => 1,
+ 'total_networks' => 1,
+ 'orders_in_progress'=> 1,
+ 'orders_completed' => 1,
+ 'orders_canceled' => 1,
+ ]);
+
+ expect(Metrics::forCompany(
+ $company,
+ new DateTime('2026-07-01 00:00:00'),
+ new DateTime('2026-07-31 23:59:59')
+ )->with()->get())->toMatchArray([
+ 'total_products' => 1,
+ 'total_stores' => 1,
+ 'total_networks' => 1,
+ 'orders_in_progress'=> 1,
+ 'orders_completed' => 1,
+ 'orders_canceled' => 1,
+ ]);
+});
diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php
new file mode 100644
index 00000000..17c59f5c
--- /dev/null
+++ b/server/tests/Unit/Support/QPayTest.php
@@ -0,0 +1,340 @@
+ 'stub-invoice'];
+ }
+}
+
+function qpayWithResponses(array $responses, array &$history): QPay
+{
+ $mock = new MockHandler($responses);
+ $handler = HandlerStack::create($mock);
+ $handler->push(Middleware::history($history));
+
+ $qpay = new QPay('merchant', 'secret', 'https://storefront.test/qpay');
+ $qpay->updateRequestOption('handler', $handler);
+
+ return $qpay;
+}
+
+test('qpay client switches namespaces and sandbox hosts without losing its API path', function () {
+ $qpay = QPay::instance('merchant', 'secret', 'https://storefront.test/qpay');
+
+ expect((string) $qpay->getClient()->getConfig('base_uri'))
+ ->toBe('https://merchant.qpay.mn/v2/');
+
+ expect($qpay->setNamespace('v3'))->toBe($qpay)
+ ->and((string) $qpay->getClient()->getConfig('base_uri'))
+ ->toBe('https://merchant.qpay.mn/v3/');
+
+ expect($qpay->useSandbox())->toBe($qpay)
+ ->and((string) $qpay->getClient()->getConfig('base_uri'))
+ ->toBe('https://merchant-sandbox.qpay.mn/v3/');
+});
+
+test('qpay sends authenticated HTTP operations with deterministic payloads', function () {
+ $history = [];
+ $qpay = qpayWithResponses([
+ new Response(200, [], '{"access_token":"token-123"}'),
+ new Response(200, [], '{"invoice_id":"invoice-1"}'),
+ new Response(200, [], '{"rows":[{"payment_id":"payment-1"}]}'),
+ new Response(200, [], '{"status":"cancelled"}'),
+ new Response(200, [], '{"status":"refunded"}'),
+ ], $history);
+
+ expect($qpay->setAuthToken())->toBe($qpay);
+
+ $invoice = $qpay->createSimpleInvoice(
+ 12500,
+ 'ORDER-1',
+ 'Storefront order',
+ 'customer-1',
+ 'sender-1'
+ );
+ $payment = $qpay->getPayment('invoice-1');
+ $cancel = $qpay->paymentCancel('payment-1');
+ $refund = $qpay->paymentRefund('payment-1');
+
+ expect($invoice->invoice_id)->toBe('invoice-1')
+ ->and($payment->payment_id)->toBe('payment-1')
+ ->and($cancel->status)->toBe('cancelled')
+ ->and($refund->status)->toBe('refunded')
+ ->and($history)->toHaveCount(5)
+ ->and($history[0]['request']->getMethod())->toBe('POST')
+ ->and((string) $history[0]['request']->getUri())->toBe('https://merchant.qpay.mn/v2/auth/token')
+ ->and($history[1]['request']->getHeaderLine('Authorization'))->toBe('Bearer token-123')
+ ->and(json_decode((string) $history[1]['request']->getBody(), true))->toMatchArray([
+ 'invoice_code' => 'ORDER-1',
+ 'amount' => 12500,
+ 'callback_url' => 'https://storefront.test/qpay',
+ 'invoice_description' => 'Storefront order',
+ 'invoice_receiver_code' => 'customer-1',
+ 'sender_invoice_no' => 'sender-1',
+ ])
+ ->and(json_decode((string) $history[2]['request']->getBody(), true))->toBe([
+ 'object_type' => 'INVOICE',
+ 'object_id' => 'invoice-1',
+ ])
+ ->and($history[3]['request']->getMethod())->toBe('DELETE')
+ ->and((string) $history[3]['request']->getUri())->toContain('payment/cancel')
+ ->and((string) $history[4]['request']->getUri())->toContain('payment/refund');
+});
+
+test('qpay supports direct tokens refreshes and individual payment lookups', function () {
+ $history = [];
+ $qpay = qpayWithResponses([
+ new Response(200, [], '{"ok":true}'),
+ new Response(200, [], '{"access_token":"refreshed"}'),
+ new Response(200, [], '{"payment_id":"payment-7"}'),
+ ], $history);
+
+ expect($qpay->setCallback('https://changed.test/qpay'))->toBe($qpay)
+ ->and($qpay->setAuthToken('direct-token'))->toBe($qpay)
+ ->and($qpay->get('health')->ok)->toBeTrue()
+ ->and($qpay->refreshAuthToken()->access_token)->toBe('refreshed')
+ ->and($qpay->paymentGet('payment-7')->payment_id)->toBe('payment-7')
+ ->and($history[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer direct-token')
+ ->and((string) $history[1]['request']->getUri())->toContain('auth/refresh')
+ ->and((string) $history[2]['request']->getUri())->toContain('payment/payment-7');
+});
+
+test('qpay invoice factory authenticates and forwards invoice parameters', function () {
+ QPayInvoiceStub::$captured = [];
+
+ $invoice = QPayInvoiceStub::createInvoice('merchant', 'secret', [
+ 'invoice_code' => 'ORDER-FACTORY',
+ 'amount' => 9900,
+ ]);
+
+ expect($invoice->invoice_id)->toBe('stub-invoice')
+ ->and(QPayInvoiceStub::$captured)->toBe([
+ 'authenticated' => true,
+ 'params' => [
+ 'invoice_code' => 'ORDER-FACTORY',
+ 'amount' => 9900,
+ ],
+ ]);
+});
+
+test('qpay returns null when a payment check has no usable rows', function ($response) {
+ $history = [];
+ $qpay = qpayWithResponses([new Response(200, [], json_encode($response))], $history);
+
+ expect($qpay->getPayment('invoice-empty'))->toBeNull();
+})->with([
+ 'empty rows' => [['rows' => []]],
+ 'missing rows' => [['count' => 0]],
+ 'null body' => [null],
+]);
+
+test('qpay creates ebarimt invoices with callback defaults and explicit overrides', function () {
+ $history = [];
+ $qpay = qpayWithResponses([
+ new Response(200, [], '{"invoice_id":"default-callback"}'),
+ new Response(200, [], '{"invoice_id":"explicit-callback"}'),
+ ], $history);
+
+ $qpay->createEbarimtInvoice(
+ 'ORDER-2',
+ 'sender-2',
+ 'customer-2',
+ ['name' => 'Ada'],
+ 'Tax invoice',
+ '1',
+ '3505',
+ [['line_description' => 'Product']]
+ );
+ $qpay->createQPayInvoice([
+ 'invoice_code' => 'ORDER-3',
+ 'callback_url' => 'https://override.test/qpay',
+ ]);
+
+ $defaultPayload = json_decode((string) $history[0]['request']->getBody(), true);
+ $explicitPayload = json_decode((string) $history[1]['request']->getBody(), true);
+
+ expect($defaultPayload)->toMatchArray([
+ 'invoice_code' => 'ORDER-2',
+ 'sender_invoice_no' => 'sender-2',
+ 'invoice_receiver_code' => 'customer-2',
+ 'invoice_receiver_data' => ['name' => 'Ada'],
+ 'invoice_description' => 'Tax invoice',
+ 'tax_type' => '1',
+ 'district_code' => '3505',
+ 'callback_url' => 'https://storefront.test/qpay',
+ ])->and($explicitPayload['callback_url'])->toBe('https://override.test/qpay');
+});
+
+test('qpay inserts its configured callback when raw invoice parameters omit one', function () {
+ $history = [];
+ $qpay = qpayWithResponses([
+ new Response(200, [], '{"invoice_id":"invoice-default"}'),
+ ], $history);
+
+ $qpay->createQPayInvoice(['invoice_code' => 'ORDER-DEFAULT']);
+ $payload = json_decode((string) $history[0]['request']->getBody(), true);
+
+ expect($payload)->toBe([
+ 'invoice_code' => 'ORDER-DEFAULT',
+ 'callback_url' => 'https://storefront.test/qpay',
+ ]);
+});
+
+test('qpay code and tax helpers enforce gateway formats and deterministic fallbacks', function () {
+ expect(QPay::generateCode('order-1'))->toBe(date('Ymd') . 'order-1')
+ ->and(QPay::cleanCode(' Order #1 / paid '))->toBe('-Order-1--paid-')
+ ->and(QPay::calculateTax(110))->toBe(10.0)
+ ->and(QPay::isValidClassificationCode('2111100'))->toBeTrue()
+ ->and(QPay::isValidClassificationCode(2111100))->toBeTrue()
+ ->and(QPay::isValidClassificationCode(null))->toBeFalse()
+ ->and(QPay::isValidClassificationCode('21111'))->toBeFalse()
+ ->and(QPay::isValidTaxProductCode('319'))->toBeTrue()
+ ->and(QPay::isValidTaxProductCode(null))->toBeFalse()
+ ->and(QPay::isValidTaxProductCode('31A'))->toBeFalse()
+ ->and(QPay::isTaxFreeClassificationCode('2111100'))->toBeTrue()
+ ->and(QPay::isTaxFreeClassificationCode('6511100'))->toBeFalse()
+ ->and(QPay::isTaxFreeClassificationCode('invalid'))->toBeFalse();
+
+ expect(QPay::getCartItemClassificationCode((object) [
+ 'meta' => '{"classification_code":"2111300"}',
+ 'product_id' => null,
+ ]))->toBe('2111300')
+ ->and(QPay::getCartItemClassificationCode((object) [
+ 'meta' => ['classification_code' => 'bad'],
+ 'product_id' => null,
+ ]))->toBe('6511100')
+ ->and(QPay::getCartItemTaxProductCode((object) [
+ 'meta' => (object) ['tax_product_code' => '201'],
+ 'product_id' => null,
+ ]))->toBe('201')
+ ->and(QPay::getCartItemTaxProductCode((object) [
+ 'meta' => ['tax_product_code' => 'bad'],
+ 'product_id' => null,
+ ]))->toBe('319');
+});
+
+test('qpay tax helpers fall back to persisted product metadata', function () {
+ $connection = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('products');
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_abcdefgh',
+ 'meta' => json_encode([
+ 'classification_code' => '2111500',
+ 'tax_product_code' => '201',
+ ]),
+ ]);
+ $item = (object) ['product_id' => 'product_abcdefgh'];
+ $stringMetaItem = (object) ['meta' => json_encode(['tax_product_code' => '202'])];
+
+ expect(QPay::getCartItemClassificationCode($item))->toBe('2111500')
+ ->and(QPay::getCartItemTaxProductCode($item))->toBe('201')
+ ->and(QPay::getCartItemTaxProductCode($stringMetaItem))->toBe('202');
+});
+
+test('qpay builds receipt lines for tips delivery and pickup rules', function () {
+ $cart = new Cart();
+ $cart->forceFill([
+ 'items' => [
+ ['subtotal' => 10000, 'quantity' => 1],
+ ],
+ ]);
+
+ $quote = new ServiceQuote();
+ $quote->forceFill(['amount' => 2500]);
+
+ $deliveryLines = QPay::createQpayInitialLines($cart, $quote, [
+ 'tip' => '10%',
+ 'delivery_tip' => 500,
+ 'is_pickup' => false,
+ ]);
+ $pickupLines = QPay::createQpayInitialLines($cart, null, [
+ 'tip' => 750,
+ 'delivery_tip' => 500,
+ 'is_pickup' => true,
+ ]);
+
+ expect(array_column($deliveryLines, 'line_description'))->toBe(['Tip', 'Delivery Tip', 'Delivery Fee'])
+ ->and(array_column($deliveryLines, 'line_unit_price'))->toBe(['1000.00', '500.00', '2500.00'])
+ ->and($deliveryLines[0]['taxes'][0]['amount'])->toBe(QPay::calculateTax(1000))
+ ->and(array_column($pickupLines, 'line_description'))->toBe(['Tip'])
+ ->and($pickupLines[0]['line_unit_price'])->toBe('750.00');
+});
+
+test('qpay preserves decimal tip percentages when building receipt lines', function () {
+ $cart = new Cart();
+ $cart->forceFill([
+ 'items' => [
+ ['subtotal' => 20000, 'quantity' => 1],
+ ],
+ ]);
+
+ $lines = QPay::createQpayInitialLines($cart, null, [
+ 'tip' => '12.5%',
+ 'is_pickup' => true,
+ ]);
+
+ expect($lines[0]['line_unit_price'])->toBe('2500.00');
+});
+
+test('qpay creates deterministic test-payment shape from checkout state', function () {
+ $checkout = new Checkout();
+ $checkout->forceFill([
+ 'amount' => 4200,
+ 'currency' => 'MNT',
+ 'options' => ['qpay_invoice_id' => 'invoice-42'],
+ ]);
+
+ $payment = QPay::createTestPaymentDataFromCheckout($checkout);
+ $ebarimt = QPay::mockEbarimtResponse();
+
+ expect($payment)->toMatchArray([
+ 'payment_status' => 'PAID',
+ 'payment_amount' => 4200,
+ 'payment_currency' => 'MNT',
+ 'object_type' => 'INVOICE',
+ 'object_id' => 'invoice-42',
+ ])->and($payment['payment_id'])->toBeString()
+ ->and($payment['payment_date'])->not->toBeNull()
+ ->and($ebarimt)->toMatchArray([
+ 'ebarimt_by' => 'QPAY',
+ 'object_type' => 'INVOICE',
+ 'status' => true,
+ 'barimt_status'=> 'REGISTERED',
+ ]);
+});
diff --git a/server/tests/Unit/Support/StorefrontTest.php b/server/tests/Unit/Support/StorefrontTest.php
new file mode 100644
index 00000000..8c5cc7b7
--- /dev/null
+++ b/server/tests/Unit/Support/StorefrontTest.php
@@ -0,0 +1,482 @@
+pickup;
+ }
+
+ public function firstDispatchWithActivity(): Fleetbase\FleetOps\Models\Order
+ {
+ $this->calls[] = 'first_dispatch';
+
+ return $this;
+ }
+
+ public function setStatus(?string $status, $andSave = true)
+ {
+ if ($this->failStatus) {
+ throw new RuntimeException('status failed');
+ }
+ $this->status = $status;
+ $this->calls[] = 'status:' . $status;
+
+ return $this;
+ }
+
+ public function insertActivity(Fleetbase\FleetOps\Flow\Activity $activity, $location = [], $proof = null): string
+ {
+ $this->calls[] = 'activity:' . $activity->code;
+
+ return 'tracking_status_uuid';
+ }
+
+ public function getLastLocation()
+ {
+ return ['lat' => 47.9, 'lng' => 106.9];
+ }
+
+ public function updateStatus($code = null)
+ {
+ $this->status = $code;
+ $this->calls[] = 'update_status:' . $code;
+
+ return $this;
+ }
+
+ public function update(array $attributes = [], array $options = [])
+ {
+ $this->forceFill($attributes);
+ $this->calls[] = 'update';
+
+ return true;
+ }
+
+ public function saveQuietly(array $options = [])
+ {
+ $this->calls[] = 'save_quietly';
+
+ return true;
+ }
+
+ public function findClosestDrivers(int $distance = 6000): Illuminate\Support\Collection
+ {
+ return collect($this->drivers);
+ }
+
+ public function assignDriver($driver, $silent = false)
+ {
+ $this->calls[] = 'driver:' . $driver;
+
+ return $this;
+ }
+
+ public function dispatchWithActivity(): Fleetbase\FleetOps\Models\Order
+ {
+ $this->calls[] = 'dispatch';
+
+ return $this;
+ }
+}
+
+class StorefrontCustomerNotificationStub extends Model
+{
+ public bool $notified = false;
+ public bool $fail = false;
+
+ public function notify($notification): void
+ {
+ if ($this->fail) {
+ throw new RuntimeException('notification failed');
+ }
+ $this->notified = true;
+ }
+}
+
+class StorefrontSupportProbe extends Storefront
+{
+ public static function companyUuid(Fleetbase\Models\Company|string|null $company): ?string
+ {
+ return parent::resolveCompanyUuid($company);
+ }
+}
+
+function createStorefrontSupportSchema(): void
+{
+ $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
+ foreach (['personal_access_tokens', 'contacts', 'users', 'order_configs', 'notification_channels', 'products', 'networks', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ foreach (['stores', 'networks'] as $tableName) {
+ $schema->create($tableName, function ($table) {
+ $table->increments('id');
+ foreach ([
+ 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid',
+ 'key', 'name', 'description', 'translations', 'website', 'facebook', 'instagram',
+ 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options', 'alertable',
+ ] as $column) {
+ $table->text($column)->nullable();
+ }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->decimal('price', 12, 2)->nullable();
+ $table->decimal('sale_price', 12, 2)->nullable();
+ $table->boolean('is_on_sale')->default(false);
+ $table->softDeletes();
+ });
+ $schema->create('notification_channels', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('scheme')->nullable();
+ $table->softDeletes();
+ });
+ $schema->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->default('generated_config_uuid');
+ $table->string('company_uuid')->nullable();
+ $table->string('key')->nullable();
+ $table->string('namespace')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->boolean('core_service')->default(false);
+ $table->string('status')->nullable();
+ $table->string('version')->nullable();
+ $table->text('tags')->nullable();
+ $table->text('entities')->nullable();
+ $table->text('meta')->nullable();
+ $table->text('flow')->nullable();
+ $table->text('activities')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('users', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->softDeletes();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type');
+ $table->string('tokenable_id');
+ $table->string('name');
+ $table->string('token', 64);
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+}
+
+test('storefront resolves store and network identities products and cart item descriptions', function () {
+ createStorefrontSupportSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => '11111111-1111-4111-8111-111111111111',
+ 'public_id' => 'store_public',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => '22222222-2222-4222-8222-222222222222',
+ 'public_id' => 'network_public',
+ 'key' => 'network_key',
+ 'name' => 'Market Network',
+ ]);
+ $connection->table('products')->insert([
+ 'uuid' => 'product_uuid',
+ 'public_id' => 'product_public',
+ 'name' => 'Coffee',
+ 'price' => 1200,
+ 'sale_price' => 1000,
+ 'is_on_sale' => true,
+ ]);
+ $connection->table('notification_channels')->insert([
+ 'uuid' => 'channel_uuid',
+ 'owner_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'scheme' => 'email',
+ ]);
+ $connection->table('order_configs')->insert([
+ 'uuid' => 'order_config_uuid',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ session(['storefront_key' => 'network_key']);
+ $about = Storefront::about();
+ $network = Storefront::findAbout('network_public');
+ $product = Storefront::getProduct('product_public');
+ $description = Storefront::getFullDescriptionFromCartItem((object) [
+ 'name' => 'Coffee',
+ 'variants' => [['name' => 'Large'], ['name' => 'Hot']],
+ 'addons' => [['name' => 'Oat Milk']],
+ ]);
+
+ expect($about)->toBeInstanceOf(Network::class)
+ ->and($about->is_network)->toBeTrue()
+ ->and($network)->toBeInstanceOf(Network::class)
+ ->and($network->is_network)->toBeTrue()
+ ->and($product->name)->toBe('Coffee')
+ ->and($description)->toBe('Coffee with Variation: Large,Hot with Addons: Oat Milk')
+ ->and(Storefront::hasNotificationChannelConfigured(
+ '11111111-1111-4111-8111-111111111111',
+ 'email'
+ ))->toBeTrue()
+ ->and(Storefront::hasNotificationChannelConfigured('store_public', 'sms'))->toBeFalse()
+ ->and(Storefront::hasNotificationChannelConfigured('network_public', 'email'))->toBeFalse()
+ ->and(Storefront::hasNotificationChannelConfigured(
+ '22222222-2222-4222-8222-222222222222',
+ 'email'
+ ))->toBeFalse()
+ ->and(Storefront::hasNotificationChannelConfigured('unknown_public', 'email'))->toBeFalse()
+ ->and(Storefront::hasNotificationChannelConfigured(null, 'email'))->toBeFalse()
+ ->and(Storefront::hasNotificationChannelConfigured('', 'email'))->toBeFalse()
+ ->and(Storefront::findAbout('network_missing'))->toBeNull();
+
+ $store = Store::where('public_id', 'store_public')->first();
+ expect(Storefront::hasNotificationChannelConfigured($store, 'email'))->toBeTrue();
+});
+
+test('storefront auto acceptance and dispatch preserve pickup adhoc and driver transitions', function () {
+ createStorefrontSupportSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('order_configs')->insert([
+ 'uuid' => 'order_config_uuid',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $customer = new StorefrontCustomerNotificationStub();
+ $accepted = new StorefrontOrderStub();
+ $accepted->pickup = true;
+ $accepted->forceFill([
+ 'order_config_uuid' => 'order_config_uuid',
+ 'meta' => ['storefront_id' => 'store_public'],
+ ]);
+ $accepted->setRelation('customer', $customer);
+ $acceptedResult = Storefront::autoAcceptOrder($accepted);
+
+ $failure = new StorefrontOrderStub();
+ $failure->failStatus = true;
+ $failure->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ $failureResult = Storefront::autoAcceptOrder($failure);
+
+ $notificationFailure = new StorefrontOrderStub();
+ $failingCustomer = new StorefrontCustomerNotificationStub();
+ $failingCustomer->fail = true;
+ $notificationFailure->forceFill([
+ 'order_config_uuid' => 'order_config_uuid',
+ 'meta' => ['storefront_id' => 'store_public'],
+ ]);
+ $notificationFailure->setRelation('customer', $failingCustomer);
+ $notificationFailureResult = Storefront::autoAcceptOrder($notificationFailure);
+
+ $pickup = new StorefrontOrderStub();
+ $pickup->pickup = true;
+ $pickup->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ Storefront::autoDispatchOrder($pickup);
+
+ $adhoc = new StorefrontOrderStub();
+ $adhoc->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ Storefront::autoDispatchOrder($adhoc);
+
+ $assigned = new StorefrontOrderStub();
+ $assigned->drivers = ['driver_uuid'];
+ $assigned->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ Storefront::autoDispatchOrder($assigned, false);
+
+ $unassigned = new StorefrontOrderStub();
+ $unassigned->forceFill(['order_config_uuid' => 'order_config_uuid']);
+ Storefront::autoDispatchOrder($unassigned, false);
+
+ expect($acceptedResult)->toBe($accepted)
+ ->and($accepted->calls)->toContain('first_dispatch', 'status:accepted', 'activity:accepted')
+ ->and($customer->notified)->toBeTrue()
+ ->and($failureResult->getData(true))->toBe(['error' => 'Unable to accept order.'])
+ ->and($notificationFailureResult)->toBe($notificationFailure)
+ ->and($pickup->calls)->toContain('update_status:pickup_ready')
+ ->and($adhoc->calls)->toContain('update', 'dispatch')
+ ->and($adhoc->adhoc)->toBeTrue()
+ ->and($assigned->calls)->toContain('driver:driver_uuid', 'dispatch')
+ ->and($unassigned->calls)->toContain('update', 'dispatch')
+ ->and($unassigned->adhoc)->toBeTrue();
+});
+
+test('storefront resolves cached session and related order configuration contracts', function () {
+ createStorefrontSupportSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'order_config_uuid' => 'order_config_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('order_configs')->insert([
+ 'uuid' => 'order_config_uuid',
+ 'company_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'key' => 'storefront-config',
+ 'namespace' => 'storefront',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => '11111111-1111-4111-8111-111111111111',
+ 'storefront_key' => 'store_key',
+ ]);
+
+ $config = Fleetbase\FleetOps\Models\OrderConfig::where('uuid', 'order_config_uuid')->first();
+ $relatedOrder = new StorefrontOrderStub();
+ $relatedOrder->setRelation('orderConfig', $config);
+ $patched = Storefront::patchOrderConfig($relatedOrder);
+ $sessionConfig = Storefront::getSessionOrderConfig();
+ $firstLookup = Storefront::getOrderConfig('11111111-1111-4111-8111-111111111111');
+ $cachedLookup = Storefront::getOrderConfig('11111111-1111-4111-8111-111111111111');
+ $company = new Fleetbase\Models\Company();
+ $company->uuid = '22222222-2222-4222-8222-222222222222';
+ $connection->table('order_configs')->insert([
+ 'uuid' => 'patch_config_uuid',
+ 'company_uuid' => '33333333-3333-4333-8333-333333333333',
+ 'key' => 'storefront',
+ 'namespace' => 'system:order-config:storefront',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $companyOrder = new StorefrontOrderStub();
+ $companyOrder->forceFill(['company_uuid' => '33333333-3333-4333-8333-333333333333']);
+ $companyPatched = Storefront::patchOrderConfig($companyOrder);
+
+ expect($patched->uuid)->toBe('order_config_uuid')
+ ->and($sessionConfig->uuid)->toBe('order_config_uuid')
+ ->and($firstLookup)->toBeInstanceOf(Fleetbase\FleetOps\Models\OrderConfig::class)
+ ->and($cachedLookup)->toBe($firstLookup)
+ ->and(StorefrontSupportProbe::companyUuid($company))->toBe('22222222-2222-4222-8222-222222222222')
+ ->and(StorefrontSupportProbe::companyUuid(null))->toBe('11111111-1111-4111-8111-111111111111')
+ ->and($companyPatched->uuid)->toBe('patch_config_uuid')
+ ->and($companyOrder->order_config_uuid)->toBe('patch_config_uuid')
+ ->and($companyOrder->calls)->toContain('save_quietly');
+
+ $redis = new class {
+ public array $keys = [];
+
+ public function del(string $key): int
+ {
+ $this->keys[] = $key;
+
+ return 1;
+ }
+ };
+ app()->instance('redis', $redis);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('redis');
+ session(['storefront_store' => 'store_public']);
+
+ expect(Storefront::destroyCart('cart_uuid'))->toBe(1)
+ ->and($redis->keys)->toBe(['cart:store_public:cart_uuid']);
+});
+
+test('storefront resolves legacy customer tokens and sends immediate and queued order alerts', function () {
+ createStorefrontSupportSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ 'alertable' => json_encode(['for_new_order' => ['user_public']]),
+ ]);
+ $connection->table('users')->insert([
+ 'id' => 1,
+ 'uuid' => 'user_uuid',
+ 'public_id' => 'user_public',
+ 'name' => 'Store Operator',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('contacts')->insert([
+ 'uuid' => 'contact_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => Fleetbase\Models\User::class,
+ 'tokenable_id' => 'user_uuid',
+ 'name' => 'legacy-customer-token',
+ 'token' => hash('sha256', 'legacy-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $request = Illuminate\Http\Request::create('/storefront');
+ $request->headers->set('Customer-Token', 'legacy-secret');
+ $request->setLaravelSession(new Illuminate\Session\Store(
+ 'storefront-support-token',
+ new Illuminate\Session\ArraySessionHandler(120)
+ ));
+ app()->instance('request', $request);
+ session(['storefront_key' => 'store_key']);
+
+ $dispatcher = new class implements Illuminate\Contracts\Notifications\Dispatcher {
+ public array $calls = [];
+
+ public function send($notifiables, $notification)
+ {
+ $this->calls[] = 'send';
+ }
+
+ public function sendNow($notifiables, $notification)
+ {
+ $this->calls[] = 'send_now';
+ }
+ };
+ app()->instance(Illuminate\Notifications\ChannelManager::class, $dispatcher);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance(Illuminate\Notifications\ChannelManager::class);
+ $order = new StorefrontOrderStub();
+ $order->forceFill(['meta' => ['storefront_id' => 'store_public']]);
+
+ $customer = Storefront::getCustomerFromToken();
+ Storefront::alertNewOrder($order);
+ Storefront::alertNewOrder($order, true);
+
+ expect($customer->uuid)->toBe('contact_uuid')
+ ->and($dispatcher->calls)->toBe(['send', 'send_now']);
+});
diff --git a/tests/unit/services/storefront-dashboard-test.js b/tests/unit/services/storefront-dashboard-test.js
index 26f15783..97cf2ecc 100644
--- a/tests/unit/services/storefront-dashboard-test.js
+++ b/tests/unit/services/storefront-dashboard-test.js
@@ -48,4 +48,28 @@ module('Unit | Service | storefront-dashboard', function (hooks) {
assert.strictEqual(service.end, '2026-05-31');
assert.strictEqual(service.formattedRange, 'May 25, 2026 - May 31, 2026');
});
+
+ test('date picker presets select silently and emit one labeled period change', function (assert) {
+ const service = this.owner.lookup('service:storefront-dashboard');
+ const button = service.datePickerButtons.find((candidate) => candidate.content === 'Last 7 Days');
+ const calls = [];
+ let changes = 0;
+ const datepicker = {
+ selectDate(dates, options) {
+ calls.push({ dates, options });
+ },
+ hide() {
+ calls.push({ hidden: true });
+ },
+ };
+
+ service.on('periodChanged', () => changes++);
+ button.onClick(datepicker);
+
+ assert.strictEqual(service.label, 'Last 7 Days');
+ assert.strictEqual(changes, 1, 'refreshes dashboard widgets once');
+ assert.strictEqual(calls[0].dates.length, 2, 'selects a complete range');
+ assert.deepEqual(calls[0].options, { silent: true }, 'suppresses the delayed AirDatepicker onSelect callback');
+ assert.deepEqual(calls[1], { hidden: true }, 'closes the picker after applying the range');
+ });
});
diff --git a/tests/unit/services/storefront-test.js b/tests/unit/services/storefront-test.js
index 5d51b0cd..869cc814 100644
--- a/tests/unit/services/storefront-test.js
+++ b/tests/unit/services/storefront-test.js
@@ -62,13 +62,38 @@ module('Unit | Service | storefront', function (hooks) {
assert.strictEqual(service.activeStore.name, 'Next Store', 'resolves active store from the tracked id');
});
- test('it seeds tracked active store id from the first available store', function (assert) {
+ test('active store lookup is read-only until stores are synchronized', function (assert) {
const service = this.owner.lookup('service:storefront');
const currentUser = this.owner.lookup('service:current-user');
- const activeStore = service.activeStore;
+
+ assert.strictEqual(service.activeStore, null, 'does not select a store while a getter is being consumed');
+ assert.strictEqual(service.findActiveStore(), null, 'legacy lookup remains read-only');
+ assert.strictEqual(currentUser.getOption('activeStorefront'), undefined, 'does not persist from a getter');
+ assert.strictEqual(service.activeStoreId, undefined, 'does not mutate tracked state from a getter');
+ });
+
+ test('it synchronizes tracked active store id from the first available store', function (assert) {
+ const service = this.owner.lookup('service:storefront');
+ const currentUser = this.owner.lookup('service:current-user');
+ const activeStore = service.synchronizeActiveStore();
assert.strictEqual(activeStore.id, 'store_uuid', 'falls back to the first store');
assert.strictEqual(currentUser.getOption('activeStorefront'), 'store_uuid', 'persists the fallback store id');
assert.strictEqual(service.activeStoreId, 'store_uuid', 'tracks the fallback store id');
});
+
+ test('it replaces stale selections and clears state when no stores exist', function (assert) {
+ const service = this.owner.lookup('service:storefront');
+ const currentUser = this.owner.lookup('service:current-user');
+ const store = this.owner.lookup('service:store');
+
+ currentUser.setOption('activeStorefront', 'missing_store_uuid');
+ assert.strictEqual(service.synchronizeActiveStore().id, 'store_uuid', 'replaces a stale selection with the first loaded store');
+
+ store.stores = [];
+ assert.strictEqual(service.synchronizeActiveStore([]), null, 'supports a new user with no storefront');
+ assert.strictEqual(currentUser.getOption('activeStorefront'), undefined, 'clears the stale persisted selection');
+ assert.strictEqual(service.activeStoreId, undefined, 'clears tracked selection outside render');
+ assert.strictEqual(service.activeStore, null, 'empty state remains safe to consume from widgets');
+ });
});