From b3a42fa5ec979d7dd687f492556e617456640685 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 27 Jul 2026 04:54:00 +0800 Subject: [PATCH 1/8] test: reach full backend coverage --- scripts/coverage-summary.php | 21 + scripts/pest-bootstrap.php | 262 +- .../Commands/NotifyStorefrontOrderNearby.php | 7 +- .../Console/Commands/PurgeExpiredCarts.php | 18 +- .../Commands/SendOrderNotification.php | 50 +- .../src/Http/Controllers/ActionController.php | 7 +- .../Controllers/AddonCategoryController.php | 8 - .../src/Http/Controllers/OrderController.php | 57 +- .../src/Http/Controllers/StoreController.php | 2 +- .../Http/Controllers/v1/CartController.php | 37 +- .../Http/Controllers/v1/CatalogController.php | 2 +- .../Controllers/v1/CheckoutController.php | 219 +- .../Controllers/v1/CustomerController.php | 35 +- .../Http/Controllers/v1/NetworkController.php | 2 +- .../Http/Controllers/v1/OrderController.php | 25 +- .../Http/Controllers/v1/ProductController.php | 1 + .../Http/Controllers/v1/ReviewController.php | 7 +- .../Controllers/v1/ServiceQuoteController.php | 120 +- .../Http/Middleware/SetStorefrontSession.php | 4 +- server/src/Http/Resources/Index/Order.php | 21 +- server/src/Http/Resources/Order.php | 11 +- server/src/Http/Resources/Product.php | 18 +- server/src/Jobs/DownloadProductImageUrl.php | 13 +- server/src/Models/Cart.php | 6 +- server/src/Models/Customer.php | 2 +- server/src/Models/Network.php | 2 +- server/src/Models/NotificationChannel.php | 1 + server/src/Models/Store.php | 8 +- server/src/Models/Vote.php | 2 + .../PromotionalPushNotification.php | 35 +- server/src/Observers/FoodTruckObserver.php | 8 +- server/src/Observers/ProductObserver.php | 4 +- .../Providers/StorefrontServiceProvider.php | 4 + server/src/Rules/CartExists.php | 5 +- server/src/Support/PushNotification.php | 14 +- server/src/Support/Storefront.php | 9 +- server/tests/{Feature.php => FeatureTest.php} | 9 +- .../Unit/Console/CommandContractsTest.php | 769 ++++ .../AnalyticsControllerContractsTest.php | 307 ++ .../CategoryApiControllerContractsTest.php | 337 ++ .../CheckoutBoundaryContractsTest.php | 3160 +++++++++++++++++ .../Controllers/CheckoutCalculationTest.php | 82 + .../CustomerControllerContractsTest.php | 1566 ++++++++ .../NetworkApiControllerContractsTest.php | 490 +++ .../ProductApiControllerContractsTest.php | 691 ++++ .../PublicCommerceControllerContractsTest.php | 535 +++ .../RemainingZeroCoverageContractsTest.php | 140 + .../ReviewAndOrderControllerContractsTest.php | 1245 +++++++ .../Http/Controllers/SearchControllerTest.php | 202 ++ .../ServiceQuoteControllerContractsTest.php | 1065 ++++++ .../SmallControllerContractsTest.php | 958 +++++ .../StoreGatewayControllerContractsTest.php | 206 ++ .../UtilityControllerContractsTest.php | 79 + .../Filter/StorefrontFilterContractsTest.php | 265 ++ .../Middleware/SetStorefrontSessionTest.php | 186 + .../Http/Middleware/ThrottleRequestsTest.php | 28 + .../Http/Requests/RequestContractsTest.php | 195 + .../Http/Resources/ProductResourceTest.php | 246 ++ .../RemainingResourceContractsTest.php | 469 +++ .../Resources/SimpleResourceContractsTest.php | 366 ++ .../FrameworkIntegrationContractsTest.php | 574 +++ server/tests/Unit/Models/CartTest.php | 300 ++ .../Unit/Models/CheckoutBehaviorTest.php | 97 + server/tests/Unit/Models/CustomerTest.php | 111 + .../tests/Unit/Models/NetworkBehaviorTest.php | 215 ++ .../tests/Unit/Models/ProductBehaviorTest.php | 380 ++ .../tests/Unit/Models/StoreBehaviorTest.php | 425 +++ .../Models/StorefrontModelContractsTest.php | 598 ++++ .../NotificationContractsTest.php | 757 ++++ .../Unit/Observers/CompanyObserverTest.php | 77 + .../StorefrontServiceProviderTest.php | 116 + .../Unit/Routes/StorefrontRoutesTest.php | 100 + server/tests/Unit/Rules/RuleContractsTest.php | 169 + server/tests/Unit/Support/MetricsTest.php | 142 + server/tests/Unit/Support/QPayTest.php | 340 ++ server/tests/Unit/Support/StorefrontTest.php | 482 +++ 76 files changed, 19258 insertions(+), 268 deletions(-) rename server/tests/{Feature.php => FeatureTest.php} (94%) create mode 100644 server/tests/Unit/Console/CommandContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/CheckoutCalculationTest.php create mode 100644 server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/RemainingZeroCoverageContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/SearchControllerTest.php create mode 100644 server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/StoreGatewayControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Controllers/UtilityControllerContractsTest.php create mode 100644 server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php create mode 100644 server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php create mode 100644 server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php create mode 100644 server/tests/Unit/Http/Requests/RequestContractsTest.php create mode 100644 server/tests/Unit/Http/Resources/ProductResourceTest.php create mode 100644 server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php create mode 100644 server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php create mode 100644 server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php create mode 100644 server/tests/Unit/Models/CartTest.php create mode 100644 server/tests/Unit/Models/CheckoutBehaviorTest.php create mode 100644 server/tests/Unit/Models/CustomerTest.php create mode 100644 server/tests/Unit/Models/NetworkBehaviorTest.php create mode 100644 server/tests/Unit/Models/ProductBehaviorTest.php create mode 100644 server/tests/Unit/Models/StoreBehaviorTest.php create mode 100644 server/tests/Unit/Models/StorefrontModelContractsTest.php create mode 100644 server/tests/Unit/Notifications/NotificationContractsTest.php create mode 100644 server/tests/Unit/Observers/CompanyObserverTest.php create mode 100644 server/tests/Unit/Providers/StorefrontServiceProviderTest.php create mode 100644 server/tests/Unit/Routes/StorefrontRoutesTest.php create mode 100644 server/tests/Unit/Rules/RuleContractsTest.php create mode 100644 server/tests/Unit/Support/MetricsTest.php create mode 100644 server/tests/Unit/Support/QPayTest.php create mode 100644 server/tests/Unit/Support/StorefrontTest.php 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/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..48a83116 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'))->toDateTimeString() : Carbon::now()->startOfMonth()->toDateTimeString(); + $end = $request->has('end') ? Carbon::parse($request->input('end'))->toDateTimeString() : Carbon::now()->toDateTimeString(); // default metrics $metrics = [ @@ -146,7 +147,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/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..2ca5aee0 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -37,6 +37,110 @@ class CheckoutController extends Controller { + 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'); @@ -317,9 +421,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) { @@ -441,7 +548,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 +725,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 +799,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 +939,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 +1000,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 +1089,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'); - } - } - - return $storeLocationId; - })->unique()->filter()->map(function ($storeLocationId) { - return StoreLocation::where('public_id', $storeLocationId)->first(); - })->first(); + $foodTruckOrigin = $this->resolveFoodTruckOrigin($foodTruck); + $origin = $foodTruckOrigin ?? $origin; - if ($storeLocation) { - $origin = $storeLocation->place_uuid; - } - } + $origin = $this->resolveStoreLocationOrigin($origin, $cart); // convert payload destinations to Place $origin = Place::createFromMixed($origin); @@ -1080,14 +1158,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 +1198,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 +1232,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 +1268,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 +1363,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 +1444,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 +1609,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 +1730,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..e3b6af43 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); 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/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..2022e14c --- /dev/null +++ b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php @@ -0,0 +1,307 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['orders', '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->text('meta')->nullable(); + $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('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', + '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' => 'preparing', + 'customer_uuid' => 'customer_uuid', + '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', + 'meta' => json_encode(['total' => 900, 'currency' => 'USD']), + 'created_at' => '2026-07-02 11:00:00', + 'updated_at' => '2026-07-02 11:00:00', + ], + ]); + $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(4000) + ->and($overview['metrics']['orders']['value'])->toBe(2) + ->and($overview['metrics']['completed_orders']['value'])->toBe(1) + ->and($overview['metrics']['active_orders']['value'])->toBe(1) + ->and($overview['metrics']['cancellation_rate']['value'])->toBe(33.33) + ->and($overview['metrics']['cart_conversion']['value'])->toBe(100) + ->and($statuses['total'])->toBe(3) + ->and($products['products'][0])->toMatchArray([ + 'id' => 'product_coffee', + 'name' => 'Coffee', + 'quantity' => 2, + 'revenue' => 1200, + 'currency' => 'USD', + ]) + ->and($customers)->toMatchArray([ + 'new_customers' => 1, + 'returning_customers' => 1, + 'repeat_rate' => 50, + 'total_customers' => 2, + '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..f25fc02d --- /dev/null +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -0,0 +1,3160 @@ + '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') + ); + expect($response->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' + )); + + expect($setup->getData(true))->toBe(['error' => 'Stripe not setup.']) + ->and($update->getData(true))->toBe(['error' => 'No stripe gateway configured!']); +}); + +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' => '{}', + ]); + $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('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..2fc96fc4 --- /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('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('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..8e66e53b --- /dev/null +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -0,0 +1,691 @@ +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->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..e407e2cb --- /dev/null +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -0,0 +1,1245 @@ +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('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->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->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', + '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..cabfc5a1 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -0,0 +1,958 @@ +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->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', + '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', + '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', + ], + ]); + 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' => 1, + 'customers_count' => 1, + 'stores_count' => 2, + 'earnings_sum' => 1250, + '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']); +}); From 4348619dbc4e102f777519263381ad41185c8920 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 27 Jul 2026 14:20:02 +0800 Subject: [PATCH 2/8] ci: upload coverage to Codecov (v5) + add coverage badge Add a Codecov upload step to the backend CI using codecov-action@v5 with the organization CODECOV_TOKEN, and a coverage badge to the README. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/server.yml | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) 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 @@

License: AGPL-3.0-or-later + Coverage PHP ^8.0 Node >=18 Ember Engine From ea197b6f6e2d7842b3b5a3abf529636cc2acc950 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 3 Aug 2026 20:07:20 +0800 Subject: [PATCH 3/8] ci: add Postman API contract workflow Calls the reusable api-contract workflow in fleetbase/fleetbase to boot a full stack and run this module's Postman collection against the live API. No-ops until the org POSTMAN_API_KEY secret is set. Pinned to @dev-v0.7.53 until that branch merges to main. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/postman.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/postman.yml diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml new file mode 100644 index 00000000..22a9caf8 --- /dev/null +++ b/.github/workflows/postman.yml @@ -0,0 +1,22 @@ +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: + +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 From c07f090eb024fae50dfaa909f5cf6726be64b7cd Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 3 Aug 2026 20:28:06 +0800 Subject: [PATCH 4/8] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/postman.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 22a9caf8..e7ab6fbd 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -13,6 +13,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: contract: uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 From 9073c1c76bd4cc021ee64eac2dadfd817512574c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 3 Aug 2026 23:40:00 +0800 Subject: [PATCH 5/8] fix(test): make the Pest server_vendor symlink transient The pest runner created a persistent `vendor -> server_vendor` symlink (Pest #920 workaround for its hardcoded ../../../vendor/autoload.php) but never removed it. That leftover symlink collides with Ember's addon `vendor/` convention when this package is dev-linked into the console, breaking the Ember build on case-insensitive filesystems. Create the symlink only for the duration of the run and remove it via a shutdown hook, so it never persists outside a Pest run. Co-Authored-By: Claude Opus 4.8 --- scripts/pest-runner.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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'; From 0784d2222f7cbb50dd761966cfbe30880074e640 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 4 Aug 2026 10:11:57 +0800 Subject: [PATCH 6/8] minor spacing fix in controller --- addon/controllers/customers/index.js | 1 - 1 file changed, 1 deletion(-) 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, }, { From b4e7d2431c443b903ab229a9e6eb20f928ac35fb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 6 Aug 2026 16:51:17 +0800 Subject: [PATCH 7/8] fix storefront dashboard and Stripe checkout errors --- addon/routes/application.js | 3 +- addon/services/storefront-dashboard.js | 18 +- addon/services/storefront.js | 44 ++-- addon/utils/commerce-date-ranges.js | 5 +- .../src/Http/Controllers/ActionController.php | 8 +- .../Http/Controllers/AnalyticsController.php | 21 +- .../Http/Controllers/MetricsController.php | 4 +- .../Controllers/v1/CheckoutController.php | 82 ++++++-- server/src/Support/Metrics.php | 6 +- .../AnalyticsControllerContractsTest.php | 95 ++++++--- .../CheckoutBoundaryContractsTest.php | 194 +++++++++++++++++- .../SmallControllerContractsTest.php | 56 +++-- .../services/storefront-dashboard-test.js | 24 +++ tests/unit/services/storefront-test.js | 29 ++- 14 files changed, 459 insertions(+), 130 deletions(-) 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/server/src/Http/Controllers/ActionController.php b/server/src/Http/Controllers/ActionController.php index 48a83116..00920ab7 100644 --- a/server/src/Http/Controllers/ActionController.php +++ b/server/src/Http/Controllers/ActionController.php @@ -32,8 +32,8 @@ public function getStoreCount(Request $request) public function getMetrics(Request $request) { $store = $request->input('store'); - $start = $request->has('start') ? Carbon::parse($request->input('start'))->toDateTimeString() : Carbon::now()->startOfMonth()->toDateTimeString(); - $end = $request->has('end') ? Carbon::parse($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 = [ @@ -91,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); 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/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 2ca5aee0..375f96a3 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -33,10 +33,32 @@ 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( @@ -247,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!'); } @@ -255,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; @@ -266,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); } @@ -296,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()); } @@ -335,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 @@ -390,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()); } @@ -442,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!'); } @@ -450,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()); } @@ -476,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()); } @@ -493,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()); } 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/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php index 2022e14c..eb30761f 100644 --- a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php @@ -9,7 +9,7 @@ function createAnalyticsControllerSchema(): void $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['orders', 'stores', 'products', 'carts', 'checkouts', 'contacts', 'files'] as $table) { + foreach (['orders', 'transactions', 'stores', 'products', 'carts', 'checkouts', 'contacts', 'files'] as $table) { $schema->dropIfExists($table); } @@ -20,10 +20,18 @@ function createAnalyticsControllerSchema(): void $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(); @@ -127,36 +135,55 @@ function analyticsRequest(): Request $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('orders')->insert([ [ - 'uuid' => 'order_completed', - 'company_uuid' => 'company_uuid', - 'type' => 'storefront', - 'status' => 'completed', - 'customer_uuid' => 'customer_uuid', - '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_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' => 'preparing', - 'customer_uuid' => 'customer_uuid', - '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_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', - '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_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', @@ -205,13 +232,13 @@ function analyticsRequest(): Request $products = $controller->topProducts(analyticsRequest())->getData(true); $customers = $controller->customerInsights(analyticsRequest())->getData(true); - expect($overview['metrics']['revenue']['value'])->toBe(4000) - ->and($overview['metrics']['orders']['value'])->toBe(2) - ->and($overview['metrics']['completed_orders']['value'])->toBe(1) + 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(33.33) - ->and($overview['metrics']['cart_conversion']['value'])->toBe(100) - ->and($statuses['total'])->toBe(3) + ->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', @@ -220,10 +247,10 @@ function analyticsRequest(): Request 'currency' => 'USD', ]) ->and($customers)->toMatchArray([ - 'new_customers' => 1, + 'new_customers' => 2, 'returning_customers' => 1, - 'repeat_rate' => 50, - 'total_customers' => 2, + 'repeat_rate' => 33.33, + 'total_customers' => 3, 'known_customers' => 1, ]); }); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index f25fc02d..50954b11 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -1276,7 +1276,18 @@ public function notify($notification): void (object) ['is_pickup' => true], Request::create('/checkout') ); - expect($response->getData(true))->toBe(['error' => 'Gateway not configured correctly!']); + $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 () { @@ -1659,8 +1670,24 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode '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($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 () { @@ -1816,7 +1843,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'code' => 'stripe', 'owner_uuid' => 'store_uuid', 'type' => 'stripe', - 'config' => '{}', + 'config' => json_encode(['secret_key' => ' ']), ]); $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', @@ -2078,6 +2105,167 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ]); }); +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(); diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php index cabfc5a1..a987489e 100644 --- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -161,6 +161,8 @@ public function __construct(public Company $company) $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([ @@ -174,26 +176,44 @@ public function __construct(public Company $company) ]); $connection->table('orders')->insert([ [ - 'uuid' => 'order_paid', - 'company_uuid' => 'company_uuid', - 'customer_uuid' => 'customer_uuid', - '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_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_canceled', - 'company_uuid' => 'company_uuid', - 'customer_uuid' => 'customer_uuid', - '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', [ @@ -203,10 +223,10 @@ public function __construct(public Company $company) ])); expect($response->getData(true))->toBe([ - 'orders_count' => 1, + 'orders_count' => 2, 'customers_count' => 1, 'stores_count' => 2, - 'earnings_sum' => 1250, + 'earnings_sum' => 2000, 'currency' => 'USD', ]); }); 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'); + }); }); From af089f227ffd0088f509141baa869790755532ee Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 7 Aug 2026 20:37:41 +0800 Subject: [PATCH 8/8] fix: align storefront tests with dependency updates --- server/src/Http/Controllers/v1/ReviewController.php | 13 +++++++------ .../Controllers/CustomerControllerContractsTest.php | 4 ++-- .../ProductApiControllerContractsTest.php | 1 + .../ReviewAndOrderControllerContractsTest.php | 10 +++++++++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index e3b6af43..90a69ecc 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -203,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'); @@ -215,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, @@ -227,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/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 2fc96fc4..5211b889 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -873,7 +873,7 @@ public function message(string $to, string $message, array $media = [], array $p $table->string('path')->nullable(); $table->string('bucket')->nullable(); $table->string('type')->nullable(); - $table->integer('size')->nullable(); + $table->integer('file_size')->nullable(); $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); @@ -1427,7 +1427,7 @@ public function clear(): void $table->string('path')->nullable(); $table->string('bucket')->nullable(); $table->string('type')->nullable(); - $table->integer('size')->nullable(); + $table->integer('file_size')->nullable(); $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index 8e66e53b..01200d36 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -147,6 +147,7 @@ function createProductApiControllerSchema(): void $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(); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index e407e2cb..99391a85 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -630,7 +630,7 @@ function createReviewControllerSchema(): void $table->string('path')->nullable(); $table->string('bucket')->nullable(); $table->string('type')->nullable(); - $table->integer('size')->nullable(); + $table->integer('file_size')->nullable(); $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); @@ -719,6 +719,7 @@ public function put(string $path, string $contents, string $visibility): bool ->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'); }); @@ -788,6 +789,11 @@ public function put(string $path, string $contents, string $visibility): bool $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(); @@ -811,6 +817,8 @@ public function put(string $path, string $contents, string $visibility): bool ]); $config = Fleetbase\FleetOps\Models\OrderConfig::forceCreate([ 'uuid' => 'order_config_uuid', + 'name' => 'Storefront delivery', + 'namespace' => 'fleetbase:order-config:storefront-delivery', 'activities' => '[]', ]); $order = new OrderActionStub();