From b5a436eed07696f119c7ef2ca8c003e8ba8b6d1d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:50:41 +0800 Subject: [PATCH 1/3] feat(socket-auth): storefront customer and checkout socket tokens, channel resolvers - POST storefront/v1/customers/socket-token mints a customer principal (store key + Customer-Token); 404 while socket auth is disabled, 401 without a customer of the storefront's company. - Checkout initialization responses carry socket_token (checkout kind, scp limited to checkout.{public_id}) when socket auth is enabled; absent otherwise. - Register storefront and checkout channel resolvers with core-api's SocketChannelRegistry. - QPay capture publishes {checkout, status, order, error} on checkout.{public_id} instead of the raw payment row; a publish failure no longer fails the callback. - Require fleetbase/core-api ^1.6.69. --- composer.json | 2 +- .../Controllers/v1/CheckoutController.php | 83 ++++- .../Controllers/v1/CustomerController.php | 32 ++ .../Providers/StorefrontServiceProvider.php | 12 + server/src/Support/StorefrontSocket.php | 180 +++++++++ server/src/routes.php | 1 + .../CheckoutBoundaryContractsTest.php | 269 +++++++++++++- .../StorefrontServiceProviderTest.php | 17 + .../Unit/Routes/StorefrontRoutesTest.php | 1 + .../Unit/Support/StorefrontSocketTest.php | 350 ++++++++++++++++++ 10 files changed, 937 insertions(+), 10 deletions(-) create mode 100644 server/src/Support/StorefrontSocket.php create mode 100644 server/tests/Unit/Support/StorefrontSocketTest.php diff --git a/composer.json b/composer.json index 267085d0..f386e3db 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": "^1.6.69", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index df5aeddb..1aa9443c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -32,6 +32,7 @@ use Fleetbase\Storefront\Promotions\PromotionUnavailableException; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; @@ -385,7 +386,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa // GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning // the id — so a cash or card client could never reach its own checkout's status. // The checkout is discarded instead if one of its promotions ran out meanwhile. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); @@ -486,7 +487,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status // requires alongside the token, and nothing but the QPay path used to return it. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -718,7 +719,7 @@ public function updateStripePaymentIntent(Request $request) // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -842,7 +843,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Update checkout with invoice id $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, 'checkout' => $checkout->public_id, 'token' => $checkout->token, @@ -861,7 +862,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * response for either success or error scenarios. * - Initializes a QPay instance with the gateway configuration and sets the authentication token. * - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API. - * - Publishes the payment data or error response to the SocketCluster channel. + * - Publishes the checkout status, its order and any error to the checkout's realtime channel. * * Depending on the 'respond' flag from the request, the method returns a JSON response * or completes the processing without returning data. @@ -925,7 +926,7 @@ public function captureQPayCallback(Request $request) ]; } - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -978,7 +979,7 @@ public function captureQPayCallback(Request $request) 'error' => null, ]; - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, true); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -2143,6 +2144,74 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string'))); } + /** + * The JSON response for an initialized checkout. + * + * When realtime socket authentication is enabled it carries `socket_token`: a + * `checkout` token whose scope is exactly this checkout's channel, so a client + * (a guest included) can listen for its own payment confirmation. The field is + * absent while socket authentication is disabled. + */ + protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse + { + $socketToken = StorefrontSocket::checkoutToken($checkout); + if ($socketToken) { + $data['socket_token'] = $socketToken; + } + + return response()->json($data); + } + + /** + * Publishes a checkout's progress on its realtime channel. + * + * The payload is what a storefront client acts on — the checkout, its status, the + * order once one exists (serialized exactly as GET checkouts/status returns it) and + * any error — never the raw gateway payment record. A publish failure is logged and + * swallowed: by now the payment is recorded, and clients still recover the outcome + * through GET checkouts/status. + * + * @return array|null the published payload, or null when publishing failed + */ + protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array + { + try { + // A failed payment carries no order, so a client never completes on an error event. + $order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null; + $status = 'pending'; + if ($error) { + $status = 'failed'; + } elseif ($order) { + $status = 'completed'; + } elseif ($paid) { + $status = 'paid'; + } + + $data = [ + 'checkout' => $checkout->public_id, + 'status' => $status, + 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'error' => $error, + ]; + + SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data); + + return $data; + } catch (\Throwable $e) { + Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]); + + return null; + } + } + + /** + * Serializes a checkout's order for its realtime channel, as GET checkouts/status does. + */ + protected static function checkoutChannelOrder(Order $order): array + { + return json_decode(json_encode(new OrderResource($order)), true); + } + /** * Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile. */ diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 1aa827e4..8e47c447 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,8 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request) ]); } + /** + * Mints a realtime socket token for the signed-in customer. + * + * POST storefront/v1/customers/socket-token — authenticated like every other + * customer endpoint: the storefront key plus a Customer-Token header. The token + * is a `customer` principal scoped to the store or network the key belongs to; + * the socket server only lets it subscribe to channels the customer owns. + * Returns 404 while socket authentication is not configured on this instance. + */ + public function socketToken(Request $request) + { + if (!SocketToken::enabled()) { + return response()->apiError('Not found.', 404); + } + + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + // A customer's token is only honoured by the storefront whose company the + // customer belongs to, so a token minted against another company's key is refused. + $storefront = Storefront::about(); + if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); + } + /** * Newer core-api versions add push metadata columns to user_devices. * diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 8fd668d9..79b25d48 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; // 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. @@ -111,5 +113,15 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); + $this->registerStorefrontSocketChannels(); + } + + /** + * Registers the realtime channel prefixes storefront owns (`storefront`, `checkout`) + * with core-api's socket channel registry, so the socket server can authorize them. + */ + public function registerStorefrontSocketChannels(): void + { + StorefrontSocket::registerChannels($this->app->make(SocketChannelRegistry::class)); } } diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php new file mode 100644 index 00000000..f8d1b66a --- /dev/null +++ b/server/src/Support/StorefrontSocket.php @@ -0,0 +1,180 @@ + 'customer', + 'sub' => $customer->uuid, + 'cid' => $storefront->company_uuid, + 'cpid' => static::companyPublicId($storefront->company_uuid), + 'env' => static::ENV, + 'ids' => [$customer->uuid, $customer->public_id], + 'adm' => false, + 'scp' => null, + 'sid' => $storefront->uuid, + ]); + } + + /** + * Builds the `checkout` principal: it may only ever subscribe to its own checkout channel. + */ + public static function checkoutPrincipal(Checkout $checkout): SocketPrincipal + { + return SocketPrincipal::fromClaims([ + 'kind' => 'checkout', + 'sub' => $checkout->uuid, + 'cid' => $checkout->company_uuid, + 'cpid' => static::companyPublicId($checkout->company_uuid), + 'env' => static::ENV, + 'ids' => [$checkout->uuid, $checkout->public_id], + 'adm' => false, + 'scp' => [static::checkoutChannel($checkout)], + 'sid' => $checkout->store_uuid ?? $checkout->network_uuid, + ]); + } + + /** + * Mints the socket token returned with an initialized checkout, or null while socket auth is disabled. + */ + public static function checkoutToken(Checkout $checkout): ?array + { + if (!SocketToken::enabled()) { + return null; + } + + return SocketToken::issue(static::checkoutPrincipal($checkout)); + } + + /** + * The channel checkout progress is published on. + */ + public static function checkoutChannel(Checkout $checkout): string + { + return 'checkout.' . $checkout->public_id; + } + + /** + * Registers storefront's channel resolvers with core-api's socket channel registry. + * + * @param \Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry + */ + public static function registerChannels($registry): void + { + $registry->register('storefront', \Closure::fromCallable([static::class, 'authorizeStorefront'])); + $registry->register('checkout', \Closure::fromCallable([static::class, 'authorizeCheckout'])); + } + + /** + * `storefront.{id}` — id is a store or network uuid, public id or key. + * + * Console users and API credentials may subscribe to their own company's + * storefronts; a customer only to the storefront their token was minted for. + */ + public static function authorizeStorefront(SocketPrincipal $principal, string $id, string $channel): bool + { + $storefront = static::findStorefront($id); + + if (!$storefront || !$storefront->company_uuid || $storefront->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $principal->sid !== null && $storefront->uuid === $principal->sid; + } + + return false; + } + + /** + * `checkout.{id}` — id is a checkout uuid or public id. + * + * Console users and API credentials may subscribe to their own company's + * checkouts; a customer only to checkouts they own. A `checkout` principal never + * reaches here: its `scp` already limits it to its own channel. + */ + public static function authorizeCheckout(SocketPrincipal $principal, string $id, string $channel): bool + { + $checkout = Checkout::select(['uuid', 'public_id', 'company_uuid', 'owner_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$checkout || !$checkout->company_uuid || $checkout->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $checkout->owner_uuid !== null && $checkout->owner_uuid === $principal->sub; + } + + return false; + } + + /** + * Finds a store, then a network, by uuid, public id or key. + */ + protected static function findStorefront(string $id): Store|Network|null + { + foreach ([Store::class, Network::class] as $model) { + $storefront = $model::select(['uuid', 'company_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + }) + ->first(); + + if ($storefront) { + return $storefront; + } + } + + return null; + } + + protected static function companyPublicId(?string $companyUuid): ?string + { + if (!$companyUuid) { + return null; + } + + return Company::where('uuid', $companyUuid)->value('public_id'); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..5cec3919 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -135,6 +135,7 @@ function ($router) { $router->get('/', 'CustomerController@query'); $router->post('register-device', 'CustomerController@registerDevice'); $router->post('unregister-device', 'CustomerController@unregisterDevice'); + $router->post('socket-token', 'CustomerController@socketToken'); $router->get('places', 'CustomerController@places'); $router->get('orders', 'CustomerController@orders'); $router->get('{id}', 'CustomerController@find'); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 9113ec35..7d02eee8 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -468,6 +468,34 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note } } +class CheckoutChannelPayloadStub extends TestableCheckoutController +{ + public static bool $failSerialization = false; + + protected static function checkoutChannelOrder(Fleetbase\FleetOps\Models\Order $order): array + { + if (static::$failSerialization) { + throw new RuntimeException('Order serialization failed'); + } + + return ['id' => $order->public_id]; + } + + public static function initializedCheckoutResponse(Checkout $checkout, array $data) + { + return static::checkoutResponse($checkout, $data); + } +} + +function enableCheckoutSocketAuth(bool $enabled = true): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); +} + +afterEach(function () { + enableCheckoutSocketAuth(false); +}); + function createCheckoutBoundarySchema(): void { $connection = Model::getConnectionResolver()->connection('mysql'); @@ -2794,7 +2822,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode CheckoutQPayStub::$sandboxUsed = false; CheckoutQPayStub::$authenticated = false; Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; - $controller = new TestableCheckoutController(); + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_abcdefgh', @@ -2857,7 +2886,243 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->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); + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + // The realtime payload carries what a storefront client acts on — never the raw payment row. + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'failed', + 'order' => null, + 'error' => [ + 'error' => 'PAYMENT_NOT_PAID', + 'message' => 'Payment has not been paid!', + ], + ]], + ]); +}); + +test('qpay callback publishes paid before an order exists and survives a failed publish', 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_pending_uuid', + 'public_id' => 'checkout_pending', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => null, + 'options' => '{}', + 'token' => 'checkout-token-pending', + ], + [ + 'uuid' => 'checkout_ordered_uuid', + 'public_id' => 'checkout_ordered', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'order_uuid', + 'options' => '{}', + 'token' => 'checkout-token-ordered', + ], + ]); + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); + + $paidWithoutOrder = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_pending', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = true; + $publishFailed = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_ordered', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = false; + + expect($paidWithoutOrder->getData(true)['payment']['payment_status'])->toBe('PAID') + // A failed publish never turns a recorded payment into an error response. + ->and($publishFailed->getStatusCode())->toBe(200) + ->and($publishFailed->getData(true)['payment']['payment_status'])->toBe('PAID') + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + ['checkout.checkout_pending', [ + 'checkout' => 'checkout_pending', + 'status' => 'paid', + 'order' => null, + 'error' => null, + ]], + ]); +}); + +test('qpay callback publishes the order serialized as checkout status returns it', 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('orders')->insert([ + 'uuid' => 'status_order_uuid', + 'public_id' => 'order_status', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_abcdefgh', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'status_order_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), + 'token' => 'checkout-token', + 'captured' => true, + ]); + session(['storefront_key' => null]); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_wallet' => 'QPay', + ], + ], + ]; + TestableCheckoutController::$statusFallbackOrder = null; + TestableCheckoutController::$statusFallbackFailure = null; + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + + (new TestableCheckoutController())->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_abcdefgh', + ])); + $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; + + expect($published)->toHaveCount(1) + ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) + ->and($published[0][1]['status'])->toBe('completed') + ->and($published[0][1]['order']['id'])->toBe('order_status') + ->and($published[0][1]['error'])->toBeNull(); +}); + +test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([ + 'uuid' => 'company_uuid', + 'public_id' => 'company_public', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $initialize = function () { + $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']); + + return CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true, 'tip' => false, 'delivery_tip' => false], + Request::create('/checkout') + ); + }; + + $disabled = $initialize(); + enableCheckoutSocketAuth(); + $enabled = $initialize(); + $checkout = Checkout::where('public_id', $enabled->getData(true)['checkout'])->firstOrFail(); + $socket = $enabled->getData(true)['socket_token']; + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($socket['token']); + + expect(array_keys($disabled->getData(true)))->toBe(['checkout', 'token']) + ->and(array_keys($enabled->getData(true)))->toBe(['checkout', 'token', 'socket_token']) + ->and(array_keys($socket))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe($checkout->uuid) + ->and($claims->ids)->toBe([$checkout->uuid, $checkout->public_id]) + ->and($claims->scp)->toBe(['checkout.' . $checkout->public_id]) + ->and($claims->cid)->toBe('company_uuid') + ->and($claims->cpid)->toBe('company_public') + ->and($claims->sid)->toBe('store_uuid') + ->and($claims->env)->toBe('live'); +}); + +test('checkout response helper adds the socket token beside the existing fields', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_network', + 'company_uuid' => 'company_uuid', + 'network_uuid' => 'network_uuid', + 'token' => 'checkout-token', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + enableCheckoutSocketAuth(); + + $data = CheckoutChannelPayloadStub::initializedCheckoutResponse($checkout, ['invoice' => ['invoice_id' => 'inv'], 'checkout' => 'checkout_network', 'token' => 'checkout-token'])->getData(true); + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($data['socket_token']['token']); + + expect($data['invoice'])->toBe(['invoice_id' => 'inv']) + ->and($claims->scp)->toBe(['checkout.checkout_network']) + ->and($claims->sid)->toBe('network_uuid') + ->and($claims->cpid)->toBeNull(); }); test('single and multiple order capture reject invalid checkout tokens safely', function () { diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php index d71c6aac..485d25c6 100644 --- a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php +++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php @@ -100,6 +100,11 @@ protected function mergeConfigFrom($path, $key) { $this->calls[] = $key; } + + public function registerStorefrontSocketChannels(): void + { + $this->calls[] = 'socket-channels'; + } }; $provider->boot(); @@ -119,5 +124,17 @@ protected function mergeConfigFrom($path, $key) 'database.connections', 'storefront', 'storefront.api', + 'socket-channels', ]); }); + +test('storefront provider registers its socket channel resolvers with the core registry', function () { + $app = new Fleetbase\TestSupport\ApplicationContainer(); + $registry = new Fleetbase\Support\SocketCluster\SocketChannelRegistry(); + $app->instance(Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, $registry); + + (new StorefrontServiceProvider($app))->registerStorefrontSocketChannels(); + + expect($registry->resolve('storefront'))->toBeInstanceOf(Closure::class) + ->and($registry->resolve('checkout'))->toBeInstanceOf(Closure::class); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..b6a0dc2f 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php new file mode 100644 index 00000000..aa7ec375 --- /dev/null +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -0,0 +1,350 @@ + $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); +} + +function storefrontSocketSchema(): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['companies', 'contacts', 'personal_access_tokens', 'stores', 'networks', 'checkouts'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $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->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $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(); + }); + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, 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->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $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('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->text('options')->nullable(); + $table->string('token')->nullable(); + $table->string('order_uuid')->nullable(); + $table->boolean('captured')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaaaaaa'], + ['uuid' => 'company-b', 'public_id' => 'company_bbbbbbb'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'store_key_a'], + ['uuid' => 'store-a2', 'public_id' => 'store_aaaaaa2', 'company_uuid' => 'company-a', 'key' => 'store_key_a2'], + ['uuid' => 'store-b', 'public_id' => 'store_bbbbbbb', 'company_uuid' => 'company-b', 'key' => 'store_key_b'], + ['uuid' => 'store-orphan', 'public_id' => 'store_orphan1', 'company_uuid' => null, 'key' => 'store_key_orphan'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ]); + $connection->table('checkouts')->insert([ + ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], + ['uuid' => 'checkout-guest', 'public_id' => 'chkt_guest01', 'company_uuid' => 'company-a', 'store_uuid' => null, 'network_uuid' => 'network-a', 'owner_uuid' => null, 'token' => 'checkout_g'], + ['uuid' => 'checkout-b', 'public_id' => 'chkt_bbbbbbb', 'company_uuid' => 'company-b', 'store_uuid' => 'store-b', 'network_uuid' => null, 'owner_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'token' => 'checkout_b'], + ['uuid' => 'checkout-orphan', 'public_id' => 'chkt_orphan1', 'company_uuid' => null, 'store_uuid' => null, 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_o'], + ]); + + foreach (['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' => 'customer-secret-a', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' => 'customer-secret-b'] as $contact => $secret) { + $connection->table('personal_access_tokens')->insert([ + 'name' => $contact, + 'token' => hash('sha256', $secret), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} + +function storefrontSocketTokenRequest(?string $storefrontKey, ?string $customerToken): Request +{ + $request = Request::create('/storefront/v1/customers/socket-token', 'POST'); + $request->setLaravelSession(new SessionStore('storefront-socket-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + session(['storefront_key' => $storefrontKey]); + + return $request; +} + +function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge(['kind' => $kind, 'sub' => $kind . '-subject', 'cid' => 'company-a'], $claims)); +} + +beforeEach(function () { + storefrontSocketSchema(); + storefrontSocketEnable(false); +}); + +afterEach(function () { + storefrontSocketEnable(false); + session(['storefront_key' => null]); +}); + +test('customer socket token is not found while socket auth is disabled', function () { + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not found.']); +}); + +test('customer socket token requires an authenticated customer of the storefront company', function () { + storefrontSocketEnable(); + $controller = new CustomerController(); + + $noCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', null)); + $unknownCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', 'not-a-real-token')); + $noStorefront = $controller->socketToken(storefrontSocketTokenRequest(null, 'customer-secret-a')); + $otherCompany = $controller->socketToken(storefrontSocketTokenRequest('store_key_b', 'customer-secret-a')); + + foreach ([$noCustomer, $unknownCustomer, $noStorefront, $otherCompany] as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to create a socket token for customer.']); + } +}); + +test('customer socket token mints a customer principal scoped to the store', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + $body = $response->getData(true); + $claims = SocketToken::verify($body['token']); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($body))->toBe(['token', 'expires_in', 'expires_at']) + ->and($body['expires_in'])->toBe(900) + ->and($claims)->toBeInstanceOf(SocketPrincipal::class) + ->and($claims->kind)->toBe('customer') + ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + ->and($claims->cid)->toBe('company-a') + ->and($claims->cpid)->toBe('company_aaaaaaa') + ->and($claims->sid)->toBe('store-a') + ->and($claims->env)->toBe('live') + ->and($claims->adm)->toBeFalse() + ->and($claims->scp)->toBeNull(); +}); + +test('customer socket token minted with a network key is scoped to the network', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('network_key_a', 'customer-secret-a')); + $claims = SocketToken::verify($response->getData(true)['token']); + + expect($claims->kind)->toBe('customer') + ->and($claims->sid)->toBe('network-a') + ->and($claims->cid)->toBe('company-a'); +}); + +test('checkout principal is limited by scope to its own checkout channel', function () { + $storeCheckout = Checkout::where('uuid', 'checkout-a')->firstOrFail(); + $networkCheckout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + $orphanCheckout = Checkout::where('uuid', 'checkout-orphan')->firstOrFail(); + + $store = StorefrontSocket::checkoutPrincipal($storeCheckout); + $network = StorefrontSocket::checkoutPrincipal($networkCheckout); + $orphan = StorefrontSocket::checkoutPrincipal($orphanCheckout); + + expect($store->kind)->toBe('checkout') + ->and($store->sub)->toBe('checkout-a') + ->and($store->ids)->toBe(['checkout-a', 'chkt_aaaaaaa']) + ->and($store->cid)->toBe('company-a') + ->and($store->cpid)->toBe('company_aaaaaaa') + ->and($store->scp)->toBe(['checkout.chkt_aaaaaaa']) + ->and($store->sid)->toBe('store-a') + ->and($store->env)->toBe('live') + ->and($network->sid)->toBe('network-a') + ->and($network->scp)->toBe(['checkout.chkt_guest01']) + ->and($orphan->cid)->toBeNull() + ->and($orphan->cpid)->toBeNull() + ->and(StorefrontSocket::checkoutChannel($storeCheckout))->toBe('checkout.chkt_aaaaaaa'); +}); + +test('checkout socket token is only minted while socket auth is enabled', function () { + $checkout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + + $disabled = StorefrontSocket::checkoutToken($checkout); + storefrontSocketEnable(); + $enabled = StorefrontSocket::checkoutToken($checkout); + $claims = SocketToken::verify($enabled['token']); + + expect($disabled)->toBeNull() + ->and(array_keys($enabled))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe('checkout-guest') + ->and($claims->scp)->toBe(['checkout.chkt_guest01']) + ->and($claims->cid)->toBe('company-a'); +}); + +test('storefront channels resolve stores and networks by uuid public id or key within the company', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + + expect(StorefrontSocket::authorizeStorefront($user, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_key_a', 'storefront.store_key_a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($api, 'network_aaaaaaa', 'storefront.network_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_bbbbbbb', 'storefront.store_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_orphan1', 'storefront.store_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_missing', 'storefront.store_missing'))->toBeFalse(); +}); + +test('storefront channels let a customer subscribe only to the storefront their token names', function () { + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $networkMember = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'network-a']); + $unscoped = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'cid' => 'company-b', 'sid' => 'store-a']); + $driver = storefrontSocketPrincipal('driver', ['sid' => 'store-a']); + + expect(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaa2', 'storefront.store_aaaaaa2'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'network-a', 'storefront.network-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($unscoped, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($foreign, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($driver, 'store-a', 'storefront.store-a'))->toBeFalse(); +}); + +test('checkout channels allow the company and the owning customer only', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + $owner = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $stranger = storefrontSocketPrincipal('customer', ['sub' => 'contact-other', 'sid' => 'store-a']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'cid' => 'company-b', 'sid' => 'store-b']); + $checkout = storefrontSocketPrincipal('checkout', ['sub' => 'checkout-a', 'scp' => ['checkout.chkt_aaaaaaa']]); + $driver = storefrontSocketPrincipal('driver'); + + expect(StorefrontSocket::authorizeCheckout($user, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($api, 'checkout-a', 'checkout.checkout-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_orphan1', 'checkout.chkt_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_missing', 'checkout.chkt_missing'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_guest01', 'checkout.chkt_guest01'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($stranger, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($foreign, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($checkout, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($driver, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse(); +}); + +test('storefront registers its storefront and checkout channel resolvers', function () { + $registry = new SocketChannelRegistry(); + + StorefrontSocket::registerChannels($registry); + + $storefront = $registry->resolve('storefront'); + $checkout = $registry->resolve('checkout'); + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + + expect($storefront)->toBeInstanceOf(Closure::class) + ->and($checkout)->toBeInstanceOf(Closure::class) + ->and($storefront($customer, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and($checkout($customer, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and($checkout($customer, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse(); +}); + +test('customer principal is built from the customer and the storefront of the key', function () { + $customer = Contact::where('uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')->firstOrFail(); + $store = Store::where('uuid', 'store-a')->firstOrFail(); + $network = Network::where('uuid', 'network-a')->firstOrFail(); + + $forStore = StorefrontSocket::customerPrincipal($customer, $store); + $forNetwork = StorefrontSocket::customerPrincipal($customer, $network); + + expect($forStore->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'cid' => 'company-a', + 'cpid' => 'company_aaaaaaa', + 'env' => 'live', + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'adm' => false, + 'sid' => 'store-a', + ])->and($forNetwork->sid)->toBe('network-a'); +}); From fb6ecf7c8c7df2c7252f6eacf08a5838476d1492 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 20:09:15 +0800 Subject: [PATCH 2/3] feat(chat): customer chat with the driver delivering their order - storefront/v1/orders/{id}/chat: show (starts the chat), messages (cursor pagination), send (text and up to four photos) and read (receipts), for the signed-in customer's own orders in this storefront. - The chat is a core chat channel tagged with the order in its meta, so the driver sees it in Navigator; participants are kept to the customer and the currently assigned driver, and it is started when a driver is assigned. - Customers can read but not send once the order is completed, canceled or expired. - Driver messages are pushed to the customer through storefront push, the inbox and the customer's broadcast channel. - Customer socket tokens include the customer's user uuid so they can subscribe to chat_channel.{uuid}, which core authorizes by participant. --- .../Controllers/v1/OrderChatController.php | 245 ++++++ .../Requests/SendOrderChatMessageRequest.php | 33 + server/src/Http/Resources/OrderChat.php | 56 ++ .../src/Http/Resources/OrderChatMessage.php | 67 ++ .../Listeners/HandleOrderDriverAssigned.php | 9 + .../StorefrontOrderChatMessage.php | 96 +++ server/src/Observers/ChatMessageObserver.php | 37 + .../Providers/StorefrontServiceProvider.php | 1 + server/src/Support/OrderChat.php | 137 +++ server/src/Support/StorefrontSocket.php | 4 +- server/src/routes.php | 6 +- .../Controllers/OrderChatControllerTest.php | 795 ++++++++++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 4 + .../Unit/Support/StorefrontSocketTest.php | 15 +- 14 files changed, 1498 insertions(+), 7 deletions(-) create mode 100644 server/src/Http/Controllers/v1/OrderChatController.php create mode 100644 server/src/Http/Requests/SendOrderChatMessageRequest.php create mode 100644 server/src/Http/Resources/OrderChat.php create mode 100644 server/src/Http/Resources/OrderChatMessage.php create mode 100644 server/src/Notifications/StorefrontOrderChatMessage.php create mode 100644 server/src/Observers/ChatMessageObserver.php create mode 100644 server/src/Support/OrderChat.php create mode 100644 server/tests/Unit/Http/Controllers/OrderChatControllerTest.php diff --git a/server/src/Http/Controllers/v1/OrderChatController.php b/server/src/Http/Controllers/v1/OrderChatController.php new file mode 100644 index 00000000..e306c93b --- /dev/null +++ b/server/src/Http/Controllers/v1/OrderChatController.php @@ -0,0 +1,245 @@ +resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + $customerUserUuid = $customer->user_uuid; + $participant = OrderChat::participantFor($channel, $customerUserUuid); + $messages = $this->messageQuery($channel)->limit(static::PAGE_SIZE)->get()->reverse()->values(); + $unread = $participant ? $channel->getUnreadMessagesForParticipant($participant)->count() : 0; + + $channel->load(['participants.user']); + + return new OrderChatResource($channel, $order, $customerUserUuid, $messages, $unread); + } + + /** + * Older messages, newest first in the query and returned oldest first. + * + * Query params: `before` (a message id) and `limit` (up to 100). + */ + public function messages(Request $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $limit = min(max((int) $request->input('limit', static::PAGE_SIZE), 1), static::MAX_PAGE_SIZE); + $query = $this->messageQuery($channel); + + if ($request->filled('before')) { + $before = ChatMessage::where('chat_channel_uuid', $channel->uuid)->where('public_id', $request->input('before'))->first(); + if (!$before) { + return response()->apiError('Message not found.', 404); + } + $query->where(fn ($older) => $older->where('created_at', '<', $before->created_at) + ->orWhere(fn ($sameTime) => $sameTime->where('created_at', $before->created_at)->where('id', '<', $before->id))); + } + + $messages = $query->limit($limit)->get()->reverse()->values(); + + return response()->json(['messages' => OrderChatMessage::list($messages, $customer->user_uuid)]); + } + + /** + * Send a message as the customer. Closed once the order is completed or canceled. + */ + public function send(SendOrderChatMessageRequest $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + if (OrderChat::isClosed($order)) { + return response()->json(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed'], 423); + } + + $sender = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$sender) { + return response()->apiError('You are not part of this chat.', 403); + } + + $message = ChatMessage::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'sender_uuid' => $sender->uuid, + 'content' => (string) $request->input('content', ''), + ]); + + foreach ((array) $request->input('files', []) as $upload) { + $this->attachPhoto($message, $sender, $customer, $upload); + } + + $message->load(['sender.user', 'attachments.file', 'receipts']); + $message->notifyParticipants(); + + return response()->json(['message' => (new OrderChatMessage($message, $customer->user_uuid))->resolve()]); + } + + /** + * Mark every message from the driver as read by the customer. + */ + public function read(string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $participant = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$participant) { + return response()->apiError('You are not part of this chat.', 403); + } + + $unread = $channel->getUnreadMessagesForParticipant($participant); + foreach ($unread as $message) { + ChatReceipt::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_message_uuid' => $message->uuid, + 'participant_uuid' => $participant->uuid, + ]); + } + + return response()->json(['read' => $unread->count()]); + } + + /** + * Resolve the signed-in customer, their order in this storefront and its chat. + * + * @return array{0: Order, 1: Contact, 2: ChatChannel}|JsonResponse + */ + protected function resolveContext(string $id): array|JsonResponse + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.', 401); + } + + $order = Order::where('public_id', $id)->with(['customer', 'driverAssigned'])->first(); + if (!$order || !$this->belongsToStorefront($order)) { + return response()->apiError('Order not found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + // Once the order is finished the chat stays readable, but is not started any more. + $channel = OrderChat::isClosed($order) ? OrderChat::find($order) : OrderChat::open($order); + if (!$channel) { + return response()->json([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ], 409); + } + + return [$order, $customer, $channel]; + } + + /** + * Whether the order was placed through the storefront the request is made with. + */ + protected function belongsToStorefront(Order $order): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $storefrontId = $about->is_network ? $order->getMeta('storefront_network_id') : $order->getMeta('storefront_id'); + + return $storefrontId === $about->public_id; + } + + protected function messageQuery(ChatChannel $channel): Builder + { + return ChatMessage::where('chat_channel_uuid', $channel->uuid) + ->with(['sender.user', 'attachments.file', 'receipts']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * Store a base64 photo and attach it to the message. Written without a public ACL: the + * media bucket is private and file URLs are served through the File model. + * + * @param array{data?: string, type?: string} $upload + */ + protected function attachPhoto(ChatMessage $message, ChatParticipant $sender, Contact $customer, array $upload): void + { + $disk = config('filesystems.default'); + $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')); + $data = base64_decode((string) ($upload['data'] ?? '')); + $mimeType = (string) ($upload['type'] ?? 'image/jpeg'); + $extension = File::getExtensionFromMimeType($mimeType); + $path = 'hyperstore/' . data_get(Storefront::about(), 'public_id') . '/order-chat/' . $message->chat_channel_uuid . '/' . File::randomFileName($extension); + + Storage::disk($disk)->put($path, $data); + + $file = File::create([ + 'company_uuid' => $message->company_uuid, + 'uploader_uuid' => $customer->user_uuid, + 'subject_uuid' => $message->uuid, + 'subject_type' => ChatMessage::class, + 'disk' => $disk, + 'original_filename' => basename($path), + 'content_type' => $mimeType, + 'path' => $path, + 'bucket' => $bucket, + 'type' => 'storefront_chat_upload', + 'file_size' => strlen($data), + ]); + + ChatAttachment::create([ + 'company_uuid' => $message->company_uuid, + 'chat_channel_uuid' => $message->chat_channel_uuid, + 'chat_message_uuid' => $message->uuid, + 'sender_uuid' => $sender->uuid, + 'file_uuid' => $file->uuid, + ]); + } +} diff --git a/server/src/Http/Requests/SendOrderChatMessageRequest.php b/server/src/Http/Requests/SendOrderChatMessageRequest.php new file mode 100644 index 00000000..edc53322 --- /dev/null +++ b/server/src/Http/Requests/SendOrderChatMessageRequest.php @@ -0,0 +1,33 @@ + 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]; + } +} diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php new file mode 100644 index 00000000..16171f4e --- /dev/null +++ b/server/src/Http/Resources/OrderChat.php @@ -0,0 +1,56 @@ + $messages latest messages, oldest first + */ + public function __construct(ChatChannel $channel, public Order $order, public ?string $customerUserUuid, public iterable $messages = [], public int $unreadCount = 0) + { + parent::__construct($channel); + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * + * @return array + */ + public function toArray($request) + { + $closed = OrderChatSupport::isClosed($this->order); + $me = $this->participants->firstWhere('user_uuid', $this->customerUserUuid); + + return [ + 'id' => $this->public_id, + 'channel' => 'chat_channel.' . $this->uuid, + 'order' => $this->order->public_id, + 'status' => $closed ? 'closed' : 'open', + 'me' => data_get($me, 'public_id'), + 'participants' => $this->participants->map(fn ($participant) => [ + 'id' => $participant->public_id, + 'role' => $participant->user_uuid === $this->customerUserUuid ? 'customer' : 'driver', + 'name' => data_get($participant, 'user.name'), + // Null without an uploaded avatar, so the app shows initials instead of a placeholder. + 'avatar_url' => data_get($participant, 'user.avatar_uuid') ? data_get($participant, 'user.avatar_url') : null, + 'is_online' => (bool) $participant->is_online, + ])->values()->all(), + 'messages' => OrderChatMessage::list($this->messages, $this->customerUserUuid), + 'unread_count' => $this->unreadCount, + ]; + } +} diff --git a/server/src/Http/Resources/OrderChatMessage.php b/server/src/Http/Resources/OrderChatMessage.php new file mode 100644 index 00000000..7c0e5e44 --- /dev/null +++ b/server/src/Http/Resources/OrderChatMessage.php @@ -0,0 +1,67 @@ +sender; + $senderUser = data_get($sender, 'user_uuid'); + $isMine = $senderUser !== null && $senderUser === $this->customerUserUuid; + + return [ + 'id' => $this->public_id, + 'content' => $this->content, + 'sender' => [ + 'id' => data_get($sender, 'public_id'), + 'role' => $isMine ? 'customer' : 'driver', + 'name' => data_get($sender, 'user.name'), + ], + 'is_mine' => $isMine, + 'attachments' => $this->attachments->map(fn ($attachment) => [ + 'id' => data_get($attachment, 'file.public_id'), + 'url' => data_get($attachment, 'file.url'), + 'type' => data_get($attachment, 'file.content_type'), + ])->values()->all(), + // Read by someone other than the sender. + 'read' => $this->receipts->contains(fn ($receipt) => $receipt->participant_uuid !== $this->sender_uuid), + 'created_at' => $this->created_at, + ]; + } + + /** + * @param iterable $messages + * + * @return array + */ + public static function list(iterable $messages, ?string $customerUserUuid): array + { + $items = []; + foreach ($messages as $message) { + $items[] = (new static($message, $customerUserUuid))->resolve(); + } + + return $items; + } +} diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 6585e0f7..7687978d 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Events\OrderDriverAssigned; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned; +use Fleetbase\Storefront\Support\OrderChat; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -36,6 +38,13 @@ public function handle(OrderDriverAssigned $event) if ($order->customer) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } + + // Start the order chat so it is waiting in the driver's chat list. + try { + OrderChat::open($order); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } } } } diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php new file mode 100644 index 00000000..753060a1 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -0,0 +1,96 @@ +message, 'sender.user.name') ?: 'Your driver'; + + return $name . ' sent a message'; + } + + public function body(): string + { + $content = trim((string) $this->message->content); + + return $content !== '' ? $content : 'Sent a photo'; + } + + public function toPush($notifiable): ?PushMessage + { + return PushMessage::create($this->title(), $this->body(), $this->payload())->analyticsLabel('storefront_order_chat'); + } + + public function pushStorefronts(): array + { + return PushCredentialResolver::storefrontsForOrder($this->order); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->title(), + 'body' => $this->body(), + 'subject' => $this->title(), + 'message' => $this->body(), + ...$this->payload(), + ]; + } + + public function toBroadcast($notifiable): BroadcastMessage + { + return new BroadcastMessage(CustomerNotificationPresenter::present($this->toArray($notifiable), static::class)); + } + + public function broadcastType(): string + { + return 'order_chat_message'; + } + + /** + * @return array + */ + protected function payload(): array + { + return array_filter([ + 'type' => 'order_chat_message', + 'order' => $this->order->uuid, + 'order_id' => $this->order->public_id, + 'chat_id' => data_get($this->message, 'chatChannel.public_id'), + 'message_id' => $this->message->public_id, + 'store_id' => $this->order->getMeta('storefront_id'), + 'network_id' => $this->order->getMeta('storefront_network_id'), + ], fn ($value) => $value !== null); + } +} diff --git a/server/src/Observers/ChatMessageObserver.php b/server/src/Observers/ChatMessageObserver.php new file mode 100644 index 00000000..024bab77 --- /dev/null +++ b/server/src/Observers/ChatMessageObserver.php @@ -0,0 +1,37 @@ +chatChannel; + if (!$channel || !data_get($channel->meta, OrderChat::META_ORDER_UUID)) { + return; + } + + $order = OrderChat::orderFor($channel); + if (!$order) { + return; + } + + $customer = $order->customer; + $sender = $message->sender; + if (!$customer || !$sender || $sender->user_uuid === data_get($customer, 'user_uuid')) { + return; + } + + $customer->notify(new StorefrontOrderChatMessage($message, $order)); + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 79b25d48..b8b23369 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -35,6 +35,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\Catalog::class => \Fleetbase\Storefront\Observers\CatalogObserver::class, \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, + \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, ]; /** diff --git a/server/src/Support/OrderChat.php b/server/src/Support/OrderChat.php new file mode 100644 index 00000000..c204b090 --- /dev/null +++ b/server/src/Support/OrderChat.php @@ -0,0 +1,137 @@ +company_uuid) + ->where('meta->' . static::META_ORDER_UUID, $order->uuid) + ->first(); + } + + /** + * The order's chat, started when needed, with the customer and the assigned driver as its + * only participants. Null when either side has no user account yet, typically because no + * driver has been assigned. + */ + public static function open(Order $order): ?ChatChannel + { + $customerUserUuid = static::customerUserUuid($order); + $driverUserUuid = static::driverUserUuid($order); + if (!$customerUserUuid || !$driverUserUuid) { + return null; + } + + $channel = static::find($order); + if (!$channel) { + // Creating the channel adds its creator, the customer, as the first participant. + $channel = ChatChannel::create([ + 'company_uuid' => $order->company_uuid, + 'created_by_uuid' => $customerUserUuid, + 'name' => 'Order ' . $order->public_id, + 'meta' => [ + static::META_ORDER_UUID => $order->uuid, + static::META_ORDER_ID => $order->public_id, + ], + ]); + } + + static::syncParticipants($channel, [$customerUserUuid, $driverUserUuid]); + + return $channel; + } + + /** + * Whether the customer can still send messages. + */ + public static function isClosed(Order $order): bool + { + return in_array($order->status, static::CLOSED_STATUSES, true); + } + + /** + * The channel participant for a user, if they take part. + */ + public static function participantFor(ChatChannel $channel, ?string $userUuid): ?ChatParticipant + { + if (!$userUuid) { + return null; + } + + return ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $userUuid)->first(); + } + + /** + * The order a chat channel belongs to, when it is an order chat. + */ + public static function orderFor(ChatChannel $channel): ?Order + { + $orderUuid = data_get($channel->meta, static::META_ORDER_UUID); + + return $orderUuid ? Order::where('uuid', $orderUuid)->first() : null; + } + + public static function customerUserUuid(Order $order): ?string + { + return data_get($order->customer, 'user_uuid'); + } + + public static function driverUserUuid(Order $order): ?string + { + return data_get($order->driverAssigned, 'user_uuid'); + } + + /** + * Add missing participants and remove anyone else, such as a driver the order was taken + * away from, so a reassigned order's chat moves to the new driver. + * + * @param array $userUuids + */ + protected static function syncParticipants(ChatChannel $channel, array $userUuids): void + { + $existing = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->get(); + + foreach ($existing as $participant) { + if (!in_array($participant->user_uuid, $userUuids, true)) { + $participant->delete(); + } + } + + $present = $existing->pluck('user_uuid')->all(); + foreach ($userUuids as $userUuid) { + if (!in_array($userUuid, $present, true)) { + ChatParticipant::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'user_uuid' => $userUuid, + ]); + } + } + } +} diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php index f8d1b66a..288b9c74 100644 --- a/server/src/Support/StorefrontSocket.php +++ b/server/src/Support/StorefrontSocket.php @@ -38,7 +38,9 @@ public static function customerPrincipal(Contact $customer, Store|Network $store 'cid' => $storefront->company_uuid, 'cpid' => static::companyPublicId($storefront->company_uuid), 'env' => static::ENV, - 'ids' => [$customer->uuid, $customer->public_id], + // The user uuid lets the customer join chats they take part in, such as their + // order chat with the driver: core authorizes chat channels by participant user. + 'ids' => array_values(array_filter([$customer->uuid, $customer->public_id, $customer->user_uuid])), 'adm' => false, 'scp' => null, 'sid' => $storefront->uuid, diff --git a/server/src/routes.php b/server/src/routes.php index 5cec3919..379ffc0e 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -97,6 +97,10 @@ function ($router) { $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/chat', 'OrderChatController@show'); + $router->get('{id}/chat/messages', 'OrderChatController@messages'); + $router->post('{id}/chat/messages', 'OrderChatController@send'); + $router->post('{id}/chat/read', 'OrderChatController@read'); }); // storefront/v1/promotions @@ -104,7 +108,7 @@ function ($router) { $router->get('/', 'PromotionController@query'); $router->get('{id}', 'PromotionController@find'); }); - + // storefront/v1/notifications $router->group(['prefix' => 'notifications'], function () use ($router) { $router->get('/', 'NotificationController@query'); diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php new file mode 100644 index 00000000..3db3b4b8 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -0,0 +1,795 @@ +writes[] = [$path, $contents, $options]; + + return true; + } + + public function url($path) + { + return 'https://cdn.test/' . $path; + } + + public function exists($path) + { + return false; + } + + public function get($path) + { + return null; + } + + public function readStream($path) + { + return null; + } + + public function writeStream($path, $resource, array $options = []) + { + return false; + } + + public function getVisibility($path) + { + return 'private'; + } + + public function setVisibility($path, $visibility) + { + return false; + } + + public function prepend($path, $data) + { + return false; + } + + public function append($path, $data) + { + return false; + } + + public function delete($paths) + { + return false; + } + + public function copy($from, $to) + { + return false; + } + + public function move($from, $to) + { + return false; + } + + public function size($path) + { + return 0; + } + + public function lastModified($path) + { + return 0; + } + + public function files($directory = null, $recursive = false) + { + return []; + } + + public function allFiles($directory = null) + { + return []; + } + + public function directories($directory = null, $recursive = false) + { + return []; + } + + public function allDirectories($directory = null) + { + return []; + } + + public function makeDirectory($path) + { + return false; + } + + public function deleteDirectory($directory) + { + return false; + } +} + +function orderChatDb(): Illuminate\Database\Connection +{ + return Model::getConnectionResolver()->connection('mysql'); +} + +function createOrderChatSchema(): void +{ + $schema = orderChatDb()->getSchemaBuilder(); + foreach (['stores', 'networks', 'contacts', 'personal_access_tokens', 'users', 'drivers', 'orders', 'chat_channels', 'chat_participants', 'chat_messages', 'chat_attachments', 'chat_receipts', 'chat_logs', 'files'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, 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->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->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->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $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('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('last_login')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('drivers', 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->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('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_channels', 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('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_participants', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_messages', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->text('content')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_attachments', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->string('file_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_receipts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('participant_uuid')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_logs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('initiator_uuid')->nullable(); + $table->string('event_type')->nullable(); + $table->text('content')->nullable(); + $table->text('subjects')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $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('slug')->nullable(); + $table->string('disk')->nullable(); + $table->string('extension')->nullable(); + $table->string('content_type')->nullable(); + $table->string('path')->nullable(); + $table->string('bucket')->nullable(); + $table->string('type')->nullable(); + $table->integer('file_size')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $db = orderChatDb(); + $db->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_bloom', 'company_uuid' => 'company_uuid', 'key' => 'store_key', 'name' => 'Bloom']); + $db->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_market', 'company_uuid' => 'company_uuid', 'key' => 'network_key', 'name' => 'Market']); + $db->table('users')->insert([ + ['uuid' => 'customer_user_uuid', 'public_id' => 'user_customer', 'company_uuid' => 'company_uuid', 'name' => 'Mei Tan'], + ['uuid' => 'driver_user_uuid', 'public_id' => 'user_driver', 'company_uuid' => 'company_uuid', 'name' => 'Ravi K.'], + ['uuid' => 'second_driver_user_uuid', 'public_id' => 'user_driver_2', 'company_uuid' => 'company_uuid', 'name' => 'Arun S.'], + ]); + $db->table('contacts')->insert([ + ['uuid' => CHAT_CUSTOMER_UUID, 'public_id' => 'contact_mei', 'company_uuid' => 'company_uuid', 'user_uuid' => 'customer_user_uuid', 'type' => 'customer', 'name' => 'Mei Tan'], + ['uuid' => 'other_customer_uuid', 'public_id' => 'contact_other', 'company_uuid' => 'company_uuid', 'user_uuid' => 'other_user_uuid', 'type' => 'customer', 'name' => 'Other'], + ]); + $db->table('drivers')->insert([ + ['uuid' => 'driver_uuid', 'public_id' => 'driver_ravi', 'company_uuid' => 'company_uuid', 'user_uuid' => 'driver_user_uuid'], + ['uuid' => 'second_driver_uuid', 'public_id' => 'driver_arun', 'company_uuid' => 'company_uuid', 'user_uuid' => 'second_driver_user_uuid'], + ]); + $order = fn (string $publicId, array $overrides = []) => array_merge([ + 'uuid' => $publicId . '_uuid', + 'public_id' => $publicId, + 'company_uuid' => 'company_uuid', + 'customer_uuid' => CHAT_CUSTOMER_UUID, + 'customer_type' => Contact::class, + 'driver_assigned_uuid' => 'driver_uuid', + 'status' => 'dispatched', + 'meta' => json_encode(['storefront_id' => 'store_bloom']), + ], $overrides); + $db->table('orders')->insert([ + $order('order_active'), + $order('order_waiting', ['driver_assigned_uuid' => null]), + $order('order_done', ['status' => 'completed']), + $order('order_done_never_chatted', ['status' => 'completed']), + $order('order_other_customer', ['customer_uuid' => 'other_customer_uuid']), + $order('order_other_store', ['meta' => json_encode(['storefront_id' => 'store_elsewhere'])]), + $order('order_network', ['meta' => json_encode(['storefront_id' => 'store_bloom', 'storefront_network_id' => 'network_market'])]), + ]); + $db->table('personal_access_tokens')->insert([ + 'name' => CHAT_CUSTOMER_UUID, + 'token' => hash('sha256', 'chat-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function bindOrderChatRequest(?string $token = 'chat-secret', string $storefrontKey = 'store_key', array $input = [], string $method = 'GET', string $requestClass = Request::class): Request +{ + $request = $requestClass::create('/orders/chat', $method, $input); + if ($token) { + $request->headers->set('Customer-Token', $token); + } + $request->setLaravelSession(new Illuminate\Session\Store('order-chat', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => $storefrontKey, 'storefront_store' => null, 'storefront_network' => null]); + + return $request; +} + +function orderChatData($response): array +{ + return $response->resolve(request()); +} + +function driverSays(ChatChannel $channel, string $content, string $at, string $driverUserUuid = 'driver_user_uuid'): ChatMessage +{ + $sender = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $driverUserUuid)->first(); + + $message = ChatMessage::create(['company_uuid' => 'company_uuid', 'chat_channel_uuid' => $channel->uuid, 'sender_uuid' => $sender->uuid, 'content' => $content]); + orderChatDb()->table('chat_messages')->where('uuid', $message->uuid)->update(['created_at' => $at]); + + return $message->fresh(); +} + +/** + * Records notifications instead of sending them. + */ +function fakeChatNotifications(): object +{ + $dispatcher = new class implements Illuminate\Contracts\Notifications\Dispatcher { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sendNow($notifiables, $notification); + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + foreach (is_iterable($notifiables) && !$notifiables instanceof Model ? $notifiables : [$notifiables] as $notifiable) { + $this->sent[] = [$notifiable, $notification]; + } + } + + public function sentTo(string $uuid, string $class): array + { + return array_values(array_map(fn ($pair) => $pair[1], array_filter($this->sent, fn ($pair) => data_get($pair[0], 'uuid') === $uuid && $pair[1] instanceof $class))); + } + }; + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, $dispatcher); + Illuminate\Support\Facades\Notification::clearResolvedInstance(Illuminate\Contracts\Notifications\Dispatcher::class); + + return $dispatcher; +} + +beforeEach(function () { + createOrderChatSchema(); + // Model events generate uuids and public ids, and run ChatChannel's creator hook. + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + Model::clearBootedModels(); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Spatie\ResponseCache\Facades\ResponseCache::clearResolvedInstance('responsecache'); + // Core macros and a disabled activity log, as the framework would provide them. + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', (new Fleetbase\Expansions\Str())->humanize()); + } + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + $this->notifications = fakeChatNotifications(); +}); + +afterEach(function () { + Model::unsetEventDispatcher(); + Model::clearBootedModels(); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); +}); + +test('order chat requires a signed-in customer and their own order in this storefront', function () { + $controller = new OrderChatController(); + + bindOrderChatRequest(null); + $signedOut = $controller->show('order_active'); + bindOrderChatRequest(); + $missing = $controller->show('order_missing'); + $otherStore = $controller->show('order_other_store'); + $notMine = $controller->show('order_other_customer'); + bindOrderChatRequest('chat-secret', 'unknown_key'); + $noStorefront = $controller->show('order_active'); + + expect($signedOut->getStatusCode())->toBe(401) + ->and($missing->getStatusCode())->toBe(404) + ->and($otherStore->getStatusCode())->toBe(404) + ->and($notMine->getStatusCode())->toBe(403) + ->and($noStorefront->getStatusCode())->toBe(404); +}); + +test('order chat waits for a driver and stays closed to new chats after the order finishes', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $waiting = $controller->show('order_waiting'); + $finished = $controller->show('order_done_never_chatted'); + + expect($waiting->getStatusCode())->toBe(409) + ->and($waiting->getData(true))->toBe([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ]) + ->and($finished->getStatusCode())->toBe(409) + ->and(ChatChannel::query()->count())->toBe(0); +}); + +test('opening an order chat creates one channel with the customer and driver', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $first = orderChatData($controller->show('order_active')); + $second = orderChatData($controller->show('order_active')); + $channel = ChatChannel::query()->first(); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($channel->name)->toBe('Order order_active') + ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') + ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') + ->and($first['id'])->toBe($channel->public_id) + ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['order'])->toBe('order_active') + ->and($first['status'])->toBe('open') + ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) + ->and(collect($first['participants'])->pluck('role', 'name')->all())->toBe(['Mei Tan' => 'customer', 'Ravi K.' => 'driver']) + ->and($first['participants'][0]['is_online'])->toBeFalse() + ->and($first['participants'][0]['avatar_url'])->toBeNull() + ->and($first['messages'])->toBe([]) + ->and($first['unread_count'])->toBe(0) + ->and($second['id'])->toBe($first['id']); +}); + +test('network apps open chats for orders placed through the network', function () { + bindOrderChatRequest('chat-secret', 'network_key'); + + $response = orderChatData((new OrderChatController())->show('order_network')); + + expect($response['order'])->toBe('order_network'); +}); + +test('customers send text and photos, and only driver messages are pushed to the customer', function () { + $storage = new OrderChatFakeDisk(); + Illuminate\Support\Facades\Storage::swap($storage); + config(['filesystems.default' => 'public', 'filesystems.disks.public.bucket' => 'media-bucket']); + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + + $request = bindOrderChatRequest('chat-secret', 'store_key', [ + 'content' => 'Tower B, unit 08-112', + 'files' => [['data' => base64_encode('photo-bytes'), 'type' => 'image/png']], + ], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active')->getData(true)['message']; + $file = orderChatDb()->table('files')->first(); + + $driverMessage = driverSays($channel, '', now()->toDateTimeString()); + + expect($sent['content'])->toBe('Tower B, unit 08-112') + ->and($sent['is_mine'])->toBeTrue() + ->and($sent['sender']['role'])->toBe('customer') + ->and($sent['sender']['name'])->toBe('Mei Tan') + ->and($sent['read'])->toBeFalse() + ->and($sent['attachments'])->toHaveCount(1) + ->and($sent['attachments'][0]['type'])->toBe('image/png') + ->and($sent['attachments'][0]['url'])->toStartWith('https://cdn.test/hyperstore/store_bloom/order-chat/' . $channel->uuid . '/') + ->and($file->disk)->toBe('public') + ->and($file->bucket)->toBe('media-bucket') + ->and($file->type)->toBe('storefront_chat_upload') + ->and($file->file_size)->toBe(strlen('photo-bytes')) + ->and($file->subject_type)->toBe(ChatMessage::class) + ->and($storage->writes[0][1])->toBe('photo-bytes') + // chat photos are written without a public ACL + ->and($storage->writes[0][2])->toBe([]); + + // The customer's own message is not pushed back to them; the driver's is (here the + // observer is called directly, since this harness has no model event dispatcher). + (new ChatMessageObserver())->created($driverMessage); + $pushed = $this->notifications->sentTo(CHAT_CUSTOMER_UUID, StorefrontOrderChatMessage::class); + $driverNotified = $this->notifications->sentTo('driver_user_uuid', Fleetbase\Notifications\ChatMessageReceived::class); + + expect($pushed)->toHaveCount(1) + ->and($pushed[0]->message->uuid)->toBe($driverMessage->uuid) + ->and($driverNotified)->toHaveCount(1); +}); + +test('messages page backwards with a stable cursor and read receipts clear the unread count', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + $first = driverSays($channel, 'Picked up your flowers', '2026-10-06 13:51:00'); + $second = driverSays($channel, 'At the lobby', '2026-10-06 14:00:00'); + $third = driverSays($channel, 'Which tower?', '2026-10-06 14:00:00'); + + $opened = orderChatData($controller->show('order_active')); + $page = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $third->public_id, 'limit' => 1]), 'order_active')->getData(true); + $earlier = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $second->public_id]), 'order_active')->getData(true); + $unknown = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => 'chat_message_missing']), 'order_active'); + $all = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['limit' => 500]), 'order_active')->getData(true); + bindOrderChatRequest(); + $read = $controller->read('order_active')->getData(true); + $after = orderChatData($controller->show('order_active')); + $again = $controller->read('order_active')->getData(true); + + expect(collect($opened['messages'])->pluck('content')->all())->toBe(['Picked up your flowers', 'At the lobby', 'Which tower?']) + ->and($opened['messages'][0]['is_mine'])->toBeFalse() + ->and($opened['messages'][0]['sender']['role'])->toBe('driver') + ->and($opened['unread_count'])->toBe(3) + ->and(collect($page['messages'])->pluck('id')->all())->toBe([$second->public_id]) + ->and(collect($earlier['messages'])->pluck('id')->all())->toBe([$first->public_id]) + ->and($unknown->getStatusCode())->toBe(404) + ->and($all['messages'])->toHaveCount(3) + ->and($read)->toBe(['read' => 3]) + ->and($after['unread_count'])->toBe(0) + ->and($after['messages'][2]['read'])->toBeTrue() + ->and($again)->toBe(['read' => 0]); +}); + +test('reassigning the order moves the chat to the new driver', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['driver_assigned_uuid' => 'second_driver_uuid']); + + $response = orderChatData($controller->show('order_active')); + + expect(collect($response['participants'])->pluck('name')->sort()->values()->all())->toBe(['Arun S.', 'Mei Tan']) + ->and(ChatChannel::query()->count())->toBe(1); +}); + +test('finished orders keep their chat readable but refuse new messages', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'completed']); + + $shown = orderChatData($controller->show('order_active')); + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Thanks!'], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active'); + + expect($shown['status'])->toBe('closed') + ->and($sent->getStatusCode())->toBe(423) + ->and($sent->getData(true))->toBe(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed']) + ->and(ChatMessage::query()->count())->toBe(0); +}); + +test('customers removed from a finished order chat cannot mark messages read until it is reopened', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + // A channel whose customer participant is gone (for example removed by an operator) and + // that is not re-synced because the order has finished. + OrderChat::participantFor($channel, 'customer_user_uuid')->delete(); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'canceled']); + $read = $controller->read('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'dispatched']); + $channel->refresh(); + + expect($read->getStatusCode())->toBe(403) + ->and(OrderChat::participantFor($channel, null))->toBeNull(); + + // Re-opening the chat for an active order restores the customer. + bindOrderChatRequest(); + $controller->show('order_active'); + expect(OrderChat::participantFor($channel, 'customer_user_uuid'))->not->toBeNull(); +}); + +test('a customer missing from an open chat cannot send messages', function () { + $controller = new class extends OrderChatController { + protected function resolveContext(string $id): array|Illuminate\Http\JsonResponse + { + $context = parent::resolveContext($id); + OrderChat::participantFor($context[2], 'customer_user_uuid')?->delete(); + + return $context; + } + }; + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Hi'], 'POST', SendOrderChatMessageRequest::class); + + $sent = $controller->send($request, 'order_active'); + $shown = orderChatData($controller->show('order_active')); + + expect($sent->getStatusCode())->toBe(403) + ->and($shown['unread_count'])->toBe(0) + ->and($shown['me'])->toBeNull(); +}); + +test('every chat action reports a missing context the same way', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(null); + + expect($controller->messages(request(), 'order_active')->getStatusCode())->toBe(401) + ->and($controller->read('order_active')->getStatusCode())->toBe(401) + ->and($controller->send(SendOrderChatMessageRequest::create('/', 'POST', ['content' => 'x']), 'order_active')->getStatusCode())->toBe(401); +}); + +test('the chat observer ignores non-order chats, missing orders and customers, and customer messages', function () { + $observer = new ChatMessageObserver(); + $plain = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Dispatch']); + $orphan = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Old order', 'meta' => ['storefront_order_uuid' => 'gone_uuid']]); + $sender = ChatParticipant::where('chat_channel_uuid', $plain->uuid)->first(); + + $observer->created(new ChatMessage(['chat_channel_uuid' => 'missing_channel'])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $plain->uuid, 'sender_uuid' => $sender->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $orphan->uuid, 'sender_uuid' => $sender->uuid])); + + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + $customerParticipant = OrderChat::participantFor($chat, 'customer_user_uuid'); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => 'missing_participant'])); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['customer_uuid' => 'nobody']); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + + expect($this->notifications->sent)->toBe([]); +}); + +test('order chat helpers find the order and refuse to open without accounts', function () { + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + orderChatDb()->table('contacts')->where('uuid', CHAT_CUSTOMER_UUID)->update(['user_uuid' => null]); + $guest = Order::where('public_id', 'order_active')->first(); + + expect(OrderChat::orderFor($chat)->uuid)->toBe('order_active_uuid') + ->and(OrderChat::orderFor(new ChatChannel()))->toBeNull() + ->and(OrderChat::find($order)->uuid)->toBe($chat->uuid) + ->and(OrderChat::open($guest))->toBeNull() + ->and(OrderChat::isClosed($order))->toBeFalse(); +}); + +test('chat notifications describe the driver message for push, inbox and sockets', function () { + $order = Order::where('public_id', 'order_network')->first(); + $chat = OrderChat::open($order); + $message = driverSays($chat, 'At the lobby', now()->toDateTimeString()); + $photo = driverSays($chat, ' ', now()->toDateTimeString()); + $anonymous = new ChatMessage(['content' => 'Hi']); + + $notification = new StorefrontOrderChatMessage($message->load(['sender.user', 'chatChannel']), $order); + $array = $notification->toArray(null); + $push = $notification->toPush(null); + + expect($notification->via(null))->toBe([Fleetbase\Storefront\Push\StorefrontPushChannel::class, 'database', Fleetbase\Storefront\Notifications\Channels\SafeBroadcastChannel::class]) + ->and($array)->toMatchArray([ + 'title' => 'Ravi K. sent a message', + 'body' => 'At the lobby', + 'type' => 'order_chat_message', + 'order_id' => 'order_network', + 'chat_id' => $chat->public_id, + 'message_id' => $message->public_id, + 'store_id' => 'store_bloom', + 'network_id' => 'network_market', + ]) + ->and($push)->toBeInstanceOf(Fleetbase\Storefront\Push\PushMessage::class) + ->and($notification->broadcastType())->toBe('order_chat_message') + ->and($notification->toBroadcast(null))->toBeInstanceOf(Illuminate\Notifications\Messages\BroadcastMessage::class) + ->and($notification->pushStorefronts())->toBeArray() + ->and((new StorefrontOrderChatMessage($photo, $order))->body())->toBe('Sent a photo') + ->and((new StorefrontOrderChatMessage($anonymous, $order))->title())->toBe('Your driver sent a message'); +}); + +test('send message requests need text or photos', function () { + session(['storefront_key' => 'store_key']); + $rules = (new SendOrderChatMessageRequest())->rules(); + session(['storefront_key' => null]); + + expect($rules)->toBe([ + 'content' => 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]) + ->and((new SendOrderChatMessageRequest())->authorize())->toBeFalse(); +}); + +test('assigning a driver to a storefront order starts its chat', function () { + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); + +test('a failure to start the chat does not stop the driver assignment notification', function () { + orderChatDb()->getSchemaBuilder()->drop('chat_channels'); + $reported = []; + app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($reported) implements Illuminate\Contracts\Debug\ExceptionHandler { + public function __construct(public array &$reported) + { + } + + public function report(Throwable $e) + { + $this->reported[] = $e; + } + + public function shouldReport(Throwable $e) + { + return true; + } + + public function render($request, Throwable $e) + { + throw $e; + } + + public function renderForConsole($output, Throwable $e) + { + } + }); + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + app()->offsetUnset(Illuminate\Contracts\Debug\ExceptionHandler::class); + + expect($reported)->toHaveCount(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index b6a0dc2f..1fe112a3 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -97,5 +97,9 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], + ['GET', '{id}/chat', 'OrderChatController@show'], + ['GET', '{id}/chat/messages', 'OrderChatController@messages'], + ['POST', '{id}/chat/messages', 'OrderChatController@send'], + ['POST', '{id}/chat/read', 'OrderChatController@read'], )->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php index aa7ec375..e0e606a8 100644 --- a/server/tests/Unit/Support/StorefrontSocketTest.php +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -118,8 +118,8 @@ function storefrontSocketSchema(): void ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], ]); $connection->table('contacts')->insert([ - ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], - ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'user_uuid' => null, 'type' => 'customer'], ]); $connection->table('checkouts')->insert([ ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], @@ -202,7 +202,9 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin ->and($claims)->toBeInstanceOf(SocketPrincipal::class) ->and($claims->kind)->toBe('customer') ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + // The user uuid lets the customer join chats they take part in (core authorizes + // chat channels by participant user). + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a']) ->and($claims->cid)->toBe('company-a') ->and($claims->cpid)->toBe('company_aaaaaaa') ->and($claims->sid)->toBe('store-a') @@ -343,8 +345,11 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin 'cid' => 'company-a', 'cpid' => 'company_aaaaaaa', 'env' => 'live', - 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a'], 'adm' => false, 'sid' => 'store-a', - ])->and($forNetwork->sid)->toBe('network-a'); + ])->and($forNetwork->sid)->toBe('network-a') + // A customer without a user account has only its contact ids. + ->and(StorefrontSocket::customerPrincipal(Contact::where('uuid', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')->firstOrFail(), $store)->ids) + ->toBe(['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'contact_bbbbbbb']); }); From da400f274d3274acabd736d1c58a35095ff34d2d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 01:05:48 +0800 Subject: [PATCH 3/3] fix(chat): advertise the chat.{id} socket channel core broadcasts messages on --- server/src/Http/Resources/OrderChat.php | 3 ++- server/tests/Unit/Http/Controllers/OrderChatControllerTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php index 16171f4e..e04d164e 100644 --- a/server/src/Http/Resources/OrderChat.php +++ b/server/src/Http/Resources/OrderChat.php @@ -37,7 +37,8 @@ public function toArray($request) return [ 'id' => $this->public_id, - 'channel' => 'chat_channel.' . $this->uuid, + // Core broadcasts chat messages on `chat.{public_id}` (and `chat.{uuid}`). + 'channel' => 'chat.' . $this->public_id, 'order' => $this->order->public_id, 'status' => $closed ? 'closed' : 'open', 'me' => data_get($me, 'public_id'), diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php index 3db3b4b8..9b206316 100644 --- a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -489,7 +489,7 @@ public function clear(): void ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') ->and($first['id'])->toBe($channel->public_id) - ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['channel'])->toBe('chat.' . $channel->public_id) ->and($first['order'])->toBe('order_active') ->and($first['status'])->toBe('open') ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id)