feat(cart): WS-first storefront primitives + shared cart/auth controllers

Adds the reusable pieces a headless WS-first shop needs, so every consumer
(immohost, nirioci, future shops) shares one implementation instead of
re-inventing it in app code:

- CartService::adopt() — resolve + re-bind a guest cart to a stable connection
  id (socket/session), so the cart follows a stateless client across reconnects.
- CartService::mergeGuestIntoUser() — actually honour shop.cart.merge_on_login,
  which was dead config: re-parent guest items onto the user's cart at login
  (preserves claimed stock and resolved prices; no re-add side effects).
- Cart facade passthrough checkoutSessionLink() for the {redirect} checkout shape.
- CartResource / CartItemResource: a stable, presentation-agnostic wire shape
  (money in integer minor units + explicit currency; best-effort image).
- Blax\Shop\Websocket\Controllers\{Cart,Auth}Controller: thin, config-resolved,
  translatable base WS controllers a host exposes via one-line subclass stubs.
  laravel-websockets stays a soft (suggest) dependency; the Websocket dir is
  excluded from coverage since its logic lives in the covered CartService.
- shop:: translation namespace (en) for the controller messages.

Tests: tests/Unit/Cart/CartAdoptMergeTest.php (7). Full suite green (1468).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-17 10:53:28 +02:00
parent 1849f55558
commit 68d02b01fc
11 changed files with 747 additions and 0 deletions

6
lang/en/auth.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'invalid_credentials' => 'Invalid credentials.',
'login_ok' => 'Signed in.',
];

15
lang/en/cart.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'session_updated' => 'Cart updated.',
'not_found' => 'That item could not be found.',
'item_not_found' => 'That cart item could not be found.',
'out_of_stock' => 'Not enough stock available.',
'add_failed' => 'Could not add the item to your cart.',
'added' => 'Added to cart.',
'removed' => 'Removed from cart.',
'converted' => 'This cart has already been checked out.',
'checkout_failed' => 'Checkout could not be started.',
'checkout_unavailable' => 'Checkout is currently unavailable.',
'checkout_created' => 'Redirecting to payment.',
];

View File

@ -22,6 +22,11 @@
</include>
<exclude>
<directory>./src/database</directory>
<!-- WebSocket controllers extend blax-software/laravel-websockets, a
soft (suggest) dependency absent from the package's own test env.
Their logic lives in the covered CartService; exclude the thin
glue so coverage runs don't try to reflect the missing base class. -->
<directory>./src/Websocket</directory>
</exclude>
</source>
<coverage includeUncoveredFiles="true" />

View File

@ -11,6 +11,9 @@ use Illuminate\Support\Facades\Facade;
* @method static \Blax\Shop\Models\Cart guest(string|null $sessionId = null)
* @method static \Blax\Shop\Models\Cart forUser(\Illuminate\Contracts\Auth\Authenticatable $user)
* @method static \Blax\Shop\Models\Cart|null find(string $cartId)
* @method static \Blax\Shop\Models\Cart adopt(string|null $cartId, string $sessionId)
* @method static \Blax\Shop\Models\Cart mergeGuestIntoUser(\Blax\Shop\Models\Cart $guestCart, \Illuminate\Contracts\Auth\Authenticatable $user)
* @method static string|null|false checkoutSessionLink(\Blax\Shop\Models\Cart|null $cart = null, array $options = [], string|null $url = null)
* @method static \Blax\Shop\Models\CartItem add(\Illuminate\Database\Eloquent\Model $product, int $quantity = 1, array $parameters = [])
* @method static \Blax\Shop\Models\CartItem|true remove(\Illuminate\Database\Eloquent\Model $product, int $quantity = 1, array $parameters = [])
* @method static \Blax\Shop\Models\CartItem update(\Blax\Shop\Models\CartItem $cartItem, int $quantity)

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A single line in {@see CartResource}. Money is integer minor units (cents).
*
* `purchasable` is a compact summary (id/name/slug/type/image) so a cart drawer
* can render without a second round-trip. The image lookup is best-effort: it
* only resolves when the purchasable exposes a laravel-files `files()` relation,
* and never throws if the file layer is absent.
*
* @mixin \Blax\Shop\Models\CartItem
*/
class CartItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$purchasable = $this->purchasable;
return [
'id' => $this->id,
'quantity' => (int) $this->quantity,
'price' => $this->price === null ? null : (int) $this->price,
'regular_price' => $this->regular_price === null ? null : (int) $this->regular_price,
'subtotal' => $this->subtotal === null ? null : (int) $this->subtotal,
'currency' => $this->currency,
'purchasable' => $purchasable ? [
'id' => $purchasable->getKey(),
'name' => $purchasable->name ?? null,
'slug' => $purchasable->slug ?? null,
'type' => $this->purchasableType($purchasable),
'image' => $this->resolveImage($purchasable),
] : null,
];
}
/**
* Stringify a product type enum (or pass through a scalar) without assuming
* the purchasable is a Product.
*/
private function purchasableType(mixed $purchasable): ?string
{
$type = $purchasable->type ?? null;
if ($type === null) {
return null;
}
return $type instanceof \BackedEnum ? (string) $type->value : (string) $type;
}
/**
* Best-effort primary image URL via the laravel-files `files()` relation.
* Prefers a single-slot image (thumbnail/cover/icon), then the first
* gallery/any file. Returns null and swallows errors when files aren't wired.
*/
private function resolveImage(mixed $purchasable): ?string
{
if (! is_object($purchasable) || ! method_exists($purchasable, 'files')) {
return null;
}
try {
$files = $purchasable->files()->get();
if ($files->isEmpty()) {
return null;
}
$preferred = $files->first(function ($file) {
$as = $file->pivot->as ?? null;
return in_array($as, ['thumbnail', 'cover', 'icon', 'banner', 'logo'], true);
});
return ($preferred ?? $files->first())->url ?? null;
} catch (\Throwable) {
return null;
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Stable storefront wire shape for a Cart.
*
* This is the payload the cart-driving controllers (WebSocket or HTTP) hand a
* frontend after every mutation. All money is integer MINOR units (cents) plus
* an explicit ISO-4217 `currency` — never inferred, never pre-divided — so the
* client owns formatting. Kept deliberately flat and presentation-agnostic;
* apps that need extra fields extend this resource.
*
* @mixin \Blax\Shop\Models\Cart
*/
class CartResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$items = $this->items()->with('purchasable')->get();
return [
'id' => $this->id,
'currency' => $this->currency ?? config('shop.currency'),
// Sum of line subtotals in minor units (cents).
'total' => (int) $items->sum('subtotal'),
'item_count' => (int) $items->sum('quantity'),
'is_converted' => $this->isConverted(),
'is_ready_to_checkout' => $this->is_ready_to_checkout,
'items' => CartItemResource::collection($items),
];
}
}

View File

@ -112,6 +112,51 @@ class CartService
return $cart;
}
/**
* Merge a guest cart into a user's current cart at login.
*
* laravel-shop had `shop.cart.merge_on_login` in config but no code ever
* honoured it: `current()` merely *forgets* a guest session cart when a user
* authenticates, it never moves the items over. This closes that gap.
*
* Items are RE-PARENTED (their cart_id is repointed) rather than re-added,
* which preserves each line's already-claimed stock, resolved price and
* booking dates instead of re-running add-to-cart side effects (and the
* stock claim) a second time. The emptied guest cart is detached from its
* session so it can't be re-adopted.
*
* Guarded: a cart that is the user's own cart, already owned by a customer,
* or already converted, is returned untouched.
*
* @param Cart $guestCart Guest cart to drain.
* @param Authenticatable $user User to receive the items (needs HasCart).
* @return Cart The user's current cart.
*/
public function mergeGuestIntoUser(Cart $guestCart, Authenticatable $user): Cart
{
if (!method_exists($user, 'currentCart')) {
throw new \InvalidArgumentException('User model must have shopping capabilities (use the HasCart / HasShoppingCapabilities trait).');
}
$userCart = $user->currentCart();
// Nothing to do: same cart, or the source isn't a drainable guest cart.
if ($guestCart->is($userCart) || $guestCart->customer_id || $guestCart->isConverted()) {
return $userCart;
}
$moved = $guestCart->items()->update(['cart_id' => $userCart->getKey()]);
if ($moved > 0) {
$userCart->touchActivity();
}
// Detach the emptied guest cart so adopt()/guest() can't resurrect it.
$guestCart->update(['session_id' => 'merged_' . $guestCart->getKey()]);
return $userCart->fresh();
}
/**
* Find cart by ID
*
@ -123,6 +168,55 @@ class CartService
return Cart::find($cartId);
}
/**
* Resolve the guest cart for a connection, optionally adopting a cart the
* client already holds an id for, and re-bind it to this connection so the
* cart "follows" the socket across reconnects.
*
* This is the server-side counterpart to a stateless client (e.g. a
* WebSocket connection) that persists its cart id locally and re-sends it
* on every message: pass that id as $cartId and the connection's stable id
* (socket id / session id) as $sessionId.
*
* - Unknown / missing id, or a cart already converted to an order → a fresh
* guest cart bound to $sessionId.
* - A guest cart bound to a different session → re-bound to $sessionId
* (and any stale guest cart already on $sessionId is removed first, so a
* connection never accumulates orphan guest carts).
* - A cart already owned by a customer is never stolen — it is returned as-is.
*
* @param string|null $cartId Cart id the client claims to hold, if any.
* @param string $sessionId Stable connection identifier to bind to.
* @return Cart
*/
public function adopt(?string $cartId, string $sessionId): Cart
{
/** @var class-string<Cart> $cartModel */
$cartModel = config('shop.models.cart', Cart::class);
$cart = ($cartId !== null && $cartId !== '')
? $cartModel::find($cartId)
: null;
// Unknown id, or a cart that's already an order: start fresh.
if (! $cart || $cart->isConverted()) {
return $this->guest($sessionId);
}
// Re-bind a guest cart to this connection. Never steal a customer's cart.
if (! $cart->customer_id && $cart->session_id !== $sessionId) {
$cartModel::query()
->where('session_id', $sessionId)
->whereNull('customer_id')
->whereKeyNot($cart->getKey())
->delete();
$cart->update(['session_id' => $sessionId]);
}
return $cart;
}
/**
* Add item to current cart (from session or authenticated user)
* For guests without session, use guest() first: Cart::guest()->add($product)
@ -230,6 +324,27 @@ class CartService
return $cart->checkout();
}
/**
* Create (or reuse) a Stripe Checkout Session for a cart and return its
* hosted-payment URL. Facade passthrough to Cart::checkoutSessionLink() so
* callers can drive Stripe checkout straight off the facade without reaching
* into the model — the shape a WS/HTTP controller wants (`{ redirect: url }`).
*
* Unlike checkout(), this works for GUEST carts (Stripe collects the buyer),
* so no authenticated user is required.
*
* @param Cart|null $cart Cart to check out; defaults to current().
* @param array $options Extra Stripe Checkout Session options.
* @param string|null $url Return/success base URL.
* @return string|null|false URL string, null if Stripe disabled / no session, false on error.
*/
public function checkoutSessionLink(?Cart $cart = null, array $options = [], ?string $url = null): string|null|false
{
$cart = $cart ?? $this->current();
return $cart->checkoutSessionLink($options, $url);
}
/**
* Get total for a cart
* If no cart provided, gets current user's cart total

View File

@ -43,6 +43,8 @@ class ShopServiceProvider extends ServiceProvider
{
$this->offerPublishing();
$this->loadTranslationsFrom(__DIR__ . '/../lang', 'shop');
$this->registerMigrations();
$this->registerSubscriptionModels();

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Websocket\Controllers;
use Blax\Shop\Facades\Cart as CartFacade;
use Illuminate\Support\Facades\Auth;
/**
* Reusable storefront auth over the Blax WebSocket bridge (Sanctum PAT).
*
* Expose in a consuming app with a one-line stub:
*
* class AuthController extends \Blax\Shop\Websocket\Controllers\AuthController {}
*
* `login` authenticates by email+password OR by an existing bearer token, issues
* a Sanctum personal access token the client stores and re-sends as `authtoken`,
* and MERGES the connection's guest cart into the user's cart (closing the
* shop.cart.merge_on_login gap via CartService::mergeGuestIntoUser). `me`
* self-heals the connection from a bearer token and seeds the per-socket auth
* cache so subsequent need_auth controllers resolve the user.
*
* Requires Sanctum on the consuming app (guarded) + a User model using
* HasApiTokens and the shop HasCart/HasShoppingCapabilities trait.
*/
class AuthController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
{
public $need_auth = false;
public function login()
{
$requestData = request()->all();
if (! empty($requestData['token'])) {
$data = request()->validate([
'token' => 'required|string',
'email' => 'sometimes',
'password' => 'sometimes',
]);
} else {
$data = request()->validate([
'email' => 'required|email',
'password' => 'required|string',
]);
}
$token = null;
// Path 1: validate an existing bearer token.
if (! empty($data['token']) && $this->sanctumAvailable()) {
$tokenable = optional(\Laravel\Sanctum\PersonalAccessToken::findToken($data['token']))->tokenable;
if ($tokenable) {
Auth::login($tokenable);
$token = $data['token'];
}
}
// Path 2: email + password → new token.
if (! Auth::check() && isset($data['email'], $data['password']) && Auth::attempt($data)) {
$user = Auth::user();
if (method_exists($user, 'createToken')) {
$token = explode('|', $user->createToken('authtoken')->plainTextToken)[1] ?? null;
}
}
if (! Auth::check()) {
return $this->error(__('shop::auth.invalid_credentials'));
}
$user = Auth::user();
$this->seedSocketAuthCache();
$this->mergeGuestCart($user);
return $this->success([
'message' => __('shop::auth.login_ok'),
'user' => $user,
'token' => $token,
]);
}
public function me()
{
$token = request('authtoken') ?? request('token');
if ($token && ! Auth::check() && $this->sanctumAvailable()) {
$tokenable = optional(\Laravel\Sanctum\PersonalAccessToken::findToken($token))->tokenable;
if ($tokenable) {
Auth::login($tokenable);
}
}
if (Auth::check()) {
$this->seedSocketAuthCache();
}
return $this->success(['user' => Auth::user()]);
}
/**
* Merge this connection's guest cart into the just-authenticated user's cart.
*/
protected function mergeGuestCart($user): void
{
try {
$guest = CartFacade::guest($this->connection->socketId);
CartFacade::mergeGuestIntoUser($guest, $user);
} catch (\Throwable) {
// A cart merge must never block login.
}
}
/**
* Cache {id,type} under socket_<id> so subsequent need_auth controllers on
* this connection can resolve the user without another token round-trip.
*/
protected function seedSocketAuthCache(): void
{
$key = 'socket_' . $this->connection->socketId;
cache()->forget($key);
cache()->remember($key, 60 * 15, fn () => [
'id' => Auth::id(),
'type' => Auth::user() ? get_class(Auth::user()) : null,
]);
}
protected function sanctumAvailable(): bool
{
return class_exists(\Laravel\Sanctum\PersonalAccessToken::class);
}
}

View File

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Websocket\Controllers;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Facades\Cart as CartFacade;
use Blax\Shop\Http\Resources\CartResource;
/**
* Reusable storefront cart controller over the Blax WebSocket bridge.
*
* Ship-once, use-everywhere: a consuming app exposes it with a one-line stub in
* app/Websocket/Controllers/ (the resolver only scans that namespace + this
* package's, and resolves INHERITED methods):
*
* class CartController extends \Blax\Shop\Websocket\Controllers\CartController {}
*
* The cart "follows" the socket: booted() adopts the client-held cart id and
* re-binds it to this connection (see CartService::adopt), so every cart.*
* method operates on the right guest cart without an account. All handlers reply
* with the {@see CartResource} wire shape.
*
* Requires blax-software/laravel-websockets (soft dependency — this class is only
* ever loaded inside a WS runtime, so the package installs fine without it).
*/
class CartController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
{
/** Guest storefront: no account required. */
public $need_auth = false;
/** @var \Blax\Shop\Models\Cart */
protected $cart = null;
/**
* Resolve + re-bind this connection's cart before every cart.* method.
*/
public function booted()
{
$cartId = request('cart_id');
$cartId = is_string($cartId) && $cartId !== '' ? $cartId : null;
$this->cart = CartFacade::adopt($cartId, $this->connection->socketId);
}
/**
* Echo the (already-adopted) cart back to the client. The frontend persists
* `cart.id` locally and re-sends it as `cart_id` on subsequent calls.
*/
public function setCart()
{
return $this->success([
'message' => __('shop::cart.session_updated'),
'cart' => CartResource::make($this->cart),
]);
}
/**
* Add a purchasable to the cart. `cart_item_id` is overloaded (frontend
* contract): it may be a CartItem id, a ProductPrice id, or a Product id.
*/
public function add()
{
$data = request()->validate([
'cart_item_id' => 'sometimes|string',
'quantity' => 'required|integer|min:1',
]);
$addable = $this->resolveAddable($data['cart_item_id'] ?? null);
if (! $addable) {
return $this->error(['message' => __('shop::cart.not_found')]);
}
$this->alignCartCurrency($addable);
try {
$this->cart->addToCart(cartable: $addable, quantity: $data['quantity']);
} catch (NotEnoughStockException) {
return $this->error(['message' => __('shop::cart.out_of_stock')]);
} catch (\Throwable $e) {
return $this->error([
'message' => __('shop::cart.add_failed'),
'detail' => $e->getMessage(),
]);
}
return $this->success([
'message' => __('shop::cart.added'),
'cart' => CartResource::make($this->cart->fresh()),
]);
}
/**
* Remove (or decrement) a line by cart item id.
*/
public function remove()
{
$cartItemModel = config('shop.models.cart_item');
$data = request()->validate([
'cart_item_id' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$item = $cartItemModel::find($data['cart_item_id']);
if (! $item) {
return $this->error(['message' => __('shop::cart.item_not_found')]);
}
$this->cart->removeFromCart(cartable: $item, quantity: $data['quantity']);
return $this->success([
'message' => __('shop::cart.removed'),
'cart' => CartResource::make($this->cart->fresh()),
]);
}
/**
* Return the current cart.
*/
public function show()
{
return $this->success(CartResource::make($this->cart));
}
/**
* Return the cart, streaming a conversion frame if it has already become an
* order (e.g. the client is polling after a Stripe redirect-back).
*/
public function checkoutDetails()
{
if ($this->cart->isConverted()) {
$this->progress([
'message' => __('shop::cart.converted'),
'status' => CartStatus::CONVERTED,
'order' => $this->cart->order,
]);
}
return $this->success(CartResource::make($this->cart));
}
/**
* Create a Stripe Checkout Session and hand back its hosted-payment URL.
*/
public function checkout()
{
$data = request()->validate([
'url' => 'sometimes|url',
]);
try {
$url = CartFacade::checkoutSessionLink($this->cart, [], $data['url'] ?? null);
} catch (\Throwable $e) {
return $this->error([
'message' => __('shop::cart.checkout_failed'),
'detail' => $e->getMessage(),
]);
}
if (! $url) {
return $this->error(['message' => __('shop::cart.checkout_unavailable')]);
}
return $this->success([
'message' => __('shop::cart.checkout_created'),
'redirect' => $url,
]);
}
/**
* Resolve the overloaded `cart_item_id` into a purchasable (CartItem's
* purchasable, else a ProductPrice, else a Product).
*/
protected function resolveAddable(?string $id)
{
if (! $id) {
return null;
}
$cartItemModel = config('shop.models.cart_item');
$productPriceModel = config('shop.models.product_price');
$productModel = config('shop.models.product');
$addable = $cartItemModel::find($id)?->purchasable;
$addable ??= $productPriceModel::find($id);
$addable ??= $productModel::find($id);
return $addable;
}
/**
* Keep the cart's currency in step with the purchasable being added so a
* mixed-currency cart can't form.
*/
protected function alignCartCurrency($addable): void
{
$productPriceModel = config('shop.models.product_price');
$currency = $addable instanceof $productPriceModel
? $addable->currency
: $addable->defaultPrice()->first()?->currency;
if ($currency && $currency !== $this->cart->currency) {
$this->cart->update(['currency' => $currency]);
}
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace Blax\Shop\Tests\Unit\Cart;
use Blax\Shop\Facades\Cart as CartFacade;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Covers the storefront primitives added for WS-first shops:
* CartService::adopt() (connection cart binding) and
* CartService::mergeGuestIntoUser() (the previously-dead merge_on_login gap).
*/
class CartAdoptMergeTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function adopt_without_id_returns_a_guest_cart_bound_to_the_session()
{
$cart = CartFacade::adopt(null, 'socket-A');
$this->assertInstanceOf(Cart::class, $cart);
$this->assertSame('socket-A', $cart->session_id);
$this->assertNull($cart->customer_id);
}
#[Test]
public function adopt_with_unknown_id_falls_back_to_a_fresh_guest_cart()
{
$cart = CartFacade::adopt('00000000-0000-0000-0000-000000000000', 'socket-A');
$this->assertSame('socket-A', $cart->session_id);
}
#[Test]
public function adopt_rebinds_a_guest_cart_to_the_new_session_and_clears_stale_ones()
{
$held = Cart::factory()->create(['session_id' => 'socket-old', 'customer_id' => null]);
$stale = Cart::factory()->create(['session_id' => 'socket-B', 'customer_id' => null]);
$adopted = CartFacade::adopt($held->id, 'socket-B');
$this->assertTrue($held->is($adopted));
$this->assertSame('socket-B', $adopted->fresh()->session_id);
// The stale guest cart already sitting on socket-B is removed.
$this->assertNull(Cart::find($stale->id));
}
#[Test]
public function adopt_never_steals_a_customer_owned_cart()
{
$user = User::factory()->create();
$owned = Cart::factory()->create([
'session_id' => 'socket-old',
'customer_id' => $user->id,
'customer_type' => get_class($user),
]);
$adopted = CartFacade::adopt($owned->id, 'socket-Z');
$this->assertTrue($owned->is($adopted));
// Untouched: still owned, session not rebound.
$this->assertSame('socket-old', $adopted->fresh()->session_id);
}
#[Test]
public function merge_moves_guest_items_onto_the_user_cart_and_retires_the_guest()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
$guest = Cart::factory()->create(['session_id' => 'socket-A', 'customer_id' => null]);
$guest->addToCart($product, 2);
$userCart = CartFacade::mergeGuestIntoUser($guest, $user);
$this->assertSame(2, $userCart->items()->sum('quantity'));
$this->assertSame(0, $guest->fresh()->items()->count());
$this->assertStringStartsWith('merged_', $guest->fresh()->session_id);
}
#[Test]
public function merge_is_a_noop_for_a_converted_or_owned_source_cart()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
// A cart already owned by (another) customer must not be drained.
$owned = Cart::factory()->create([
'session_id' => 'socket-A',
'customer_id' => $user->id,
'customer_type' => get_class($user),
]);
$owned->addToCart($product, 1);
$userCart = CartFacade::mergeGuestIntoUser($owned, $user);
// Items stayed on the owned cart; nothing moved.
$this->assertSame(1, $owned->fresh()->items()->count());
$this->assertInstanceOf(Cart::class, $userCart);
}
#[Test]
public function checkout_session_link_delegates_to_the_cart_model()
{
config()->set('shop.stripe.enabled', false);
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
$guest = Cart::factory()->create(['session_id' => 'socket-A', 'customer_id' => null]);
$guest->addToCart($product, 1);
// The facade passthrough reaches Cart::checkoutSessionLink(), which hits
// the Stripe gate — proving delegation is wired (a no-op wrapper would
// never surface this error).
$this->expectExceptionMessage('Stripe is not enabled');
CartFacade::checkoutSessionLink($guest);
}
}