From dfea373c34009d61f74b0ee6f9c433043906fa9c Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Tue, 27 Oct 2026 01:37:06 +0200 Subject: [PATCH 1/3] ref(cart): keep physical fields out of the cart payload --- src/Cart/sCart.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Cart/sCart.php b/src/Cart/sCart.php index f2df9f5..b698bea 100644 --- a/src/Cart/sCart.php +++ b/src/Cart/sCart.php @@ -13,8 +13,7 @@ use Seiger\sCommerce\Services\sPriceResolver; /** - * Build cart payloads from current product pricing, attributes and delivery measurements. - * Physical values are read explicitly from attributes, never legacy columns; unreadable ones are left out. + * Build cart payloads from configured product fields, pricing and displayed attributes. */ class sCart { @@ -362,7 +361,7 @@ private function getProductFields(object $product, array $pricing = []): ?array $productFields = array_merge($product->only($this->productDetails), $pricing); - return array_merge($productFields, $attributes, $product->physicalPayload()); + return array_merge($productFields, $attributes); } /** From cccee5979f261ead1031988e70786b811a0043a6 Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Tue, 27 Oct 2026 01:37:07 +0200 Subject: [PATCH 2/3] feat(checkout): unify extensible order validation --- docs/en/developers/events.md | 35 +++-- docs/ru/developers/events.md | 34 ++-- docs/uk/README.md | 1 + docs/uk/attributes/physical-properties.md | 3 +- docs/uk/developers/events.md | 35 +++-- src/Checkout/sCheckout.php | 147 ++++++++++-------- tests/standalone/README.md | 4 + .../standalone/fixtures/checkout-harness.php | 15 +- .../scommerce-checkout-validation.php | 74 +++++++++ 9 files changed, 244 insertions(+), 104 deletions(-) create mode 100644 tests/standalone/scommerce-checkout-validation.php diff --git a/docs/en/developers/events.md b/docs/en/developers/events.md index 78853e1..54078e1 100644 --- a/docs/en/developers/events.md +++ b/docs/en/developers/events.md @@ -162,27 +162,36 @@ Event::listen('sCommerce.ResolveProductDeposit', function (array $payload) { ### sCommerce.CheckoutValidationRules -Dispatched by `sCheckout::getValidationRules(array $data)` after base rules and selected delivery-method rules have been merged. +`getValidationRules(array $data, string $context = 'default')` is the single source +of standard, delivery and plugin rules. Normal checkout uses user, delivery +and payment fields; quick checkout uses quantity, phone and email fields. +Disabled delivery/payment features do not require their corresponding method. +Both sets include product array and integer quantity rules. -- `data`: the input data passed to `getValidationRules()`. -- `rules`: the final rules array, passed by reference. Add or replace keys, or use `unset()` to remove a rule. - -Place the listener in `core/custom/packages/main/plugins/sCommerceEvents.php` when the main service provider loads that directory. Modify `rules` directly; returned arrays are not applied. Listeners see changes made by earlier listeners, so a later listener can overwrite them. +The Laravel Event payload contains `data`, `rules` passed by reference, and +`context` (`default` by default, `quick` for quick orders). Modify `rules` in place; return values are +ignored. Later listeners see earlier changes. A switch selects the base rules; +other contexts start with normal checkout rules and remain available to plugins. ```php use Illuminate\Support\Facades\Event; -Event::listen('sCommerce.CheckoutValidationRules', function (array $params) { - $rules = &$params['rules']; - $data = $params['data']; - - $rules['user.email'] = 'nullable|email|max:255'; - $rules['user.first_name'] = ['required', 'string', 'max:100']; - unset($rules['user.middle_name']); +Event::listen('sCommerce.CheckoutValidationRules', function (array $params): void { + if ($params['context'] === 'default') $params['rules']['user.email'] = 'nullable|email'; + $params['rules']['products'][] = function ($field, $items, $fail) { + // Project checks; call $fail('Message') to reject. + }; }); ``` -`setOrderData()` subsequently filters the rules to keys present in the submitted data. Adding `required` here does not make an omitted field mandatory in that partial update. Removing a rule also removes that field from this validation path; it does not automatically persist arbitrary fields. Use [`sCommerce.BeforeOrderCreate`](#scommercebeforeordercreate) to store project fields in the order `meta`. When no cart line requires shipping, `delivery.method` is already removed before this event. Quick-order validation uses its own rules and does not call this hook. +`setOrderData()` keeps its partial-update behavior: only rules for submitted +fields are applied. `processOrder()` validates prepared server order data +against the full same rule set before persistence and payment, including when +called directly. Quick checkout invokes the validator and this event once, after preparing +server product lines. Submitted quantities stay uncast in both input and lines; +integer conversion, single-product totals and deposits follow successful validation. Request-supplied `products` are ignored and replaced with +the actual product or cart lines; partial forms without products skip that rule. +The package contains no project-specific minimum attribute or policy class. These three events use the exact names documented on this page, without the `evolution.` prefix. The previous price-event names `evolution.sCommerceResolveProductPriceMode` and `evolution.sCommerceResolveProductPrice` are no longer dispatched; update project listeners and sPricing together with sCommerce. diff --git a/docs/ru/developers/events.md b/docs/ru/developers/events.md index 03bbc1f..fad03d9 100644 --- a/docs/ru/developers/events.md +++ b/docs/ru/developers/events.md @@ -190,27 +190,35 @@ Event::listen('sCommerce.ResolveProductDeposit', function (array $payload) { ### sCommerce.CheckoutValidationRules -Вызывается в `sCheckout::getValidationRules(array $data)` после объединения базовых правил с правилами выбранного способа доставки. +`getValidationRules(array $data, string $context = 'default')` — единая точка +формирования стандартных правил, правил доставки и плагинов. Обычное +оформление проверяет пользователя, доставку и оплату; быстрое — количество, +телефон и email. Отключённые доставка/оплата не требуют соответствующего метода. +Оба набора содержат правила массива товаров и целого положительного количества. -- `data` — входные данные, переданные в `getValidationRules()`. -- `rules` — итоговый массив правил по ссылке. Можно добавлять и заменять ключи или удалять их через `unset()`. - -Разместите слушатель в `core/custom/packages/main/plugins/sCommerceEvents.php`, если service provider пакета main загружает эту папку. Изменяйте `rules` напрямую: возвращённый массив не применяется. Каждый следующий слушатель видит изменения предыдущих и может их переопределить. +Laravel Event передаёт `data`, `rules` по ссылке и `context` +(по умолчанию `default`, для быстрого заказа — `quick`). Изменяйте `rules` непосредственно; результат возврата +игнорируется. Следующие слушатели видят предыдущие изменения. Базовый набор выбирается через switch; другие контексты получают правила обычного оформления и доступны плагинам. ```php use Illuminate\Support\Facades\Event; -Event::listen('sCommerce.CheckoutValidationRules', function (array $params) { - $rules = &$params['rules']; - $data = $params['data']; - - $rules['user.email'] = 'nullable|email|max:255'; - $rules['user.first_name'] = ['required', 'string', 'max:100']; - unset($rules['user.middle_name']); +Event::listen('sCommerce.CheckoutValidationRules', function (array $params): void { + if ($params['context'] === 'default') $params['rules']['user.email'] = 'nullable|email'; + $params['rules']['products'][] = function ($field, $items, $fail) { + // Правила проекта; $fail('Сообщение') отклоняет оформление. + }; }); ``` -`setOrderData()` затем оставляет только правила для ключей, присутствующих в переданных данных. Поэтому добавление `required` не делает отсутствующее поле обязательным при таком частичном обновлении. Удаление правила также исключает поле из этого пути валидации, а не обеспечивает сохранение произвольных полей. Чтобы сохранить поля проекта в `meta` заказа, используйте [`sCommerce.BeforeOrderCreate`](#scommercebeforeordercreate). Если ни одна строка корзины не требует доставки, `delivery.method` удаляется ещё до этого события. Быстрый заказ использует собственные правила и не вызывает этот хук. +`setOrderData()` сохраняет частичную проверку только переданных полей. +`processOrder()` проверяет подготовленные серверные данные тем же полным +набором правил до записи и оплаты, в том числе при прямом вызове. Быстрое +оформление вызывает валидатор и это событие один раз после подготовки серверных +позиций. Количество во вводе и позициях сохраняется без приведения к int; +преобразование, сумма отдельного товара и залог рассчитываются после проверки. `products` из запроса игнорируется: используются фактический товар или +корзина. Частичная форма без товаров не запускает правило products. +Атрибута минимума и отдельного класса политики проекта в пакете нет. Для этих трёх событий используйте точные имена с этой страницы, без префикса `evolution.`. Предыдущие ценовые события `evolution.sCommerceResolveProductPriceMode` и `evolution.sCommerceResolveProductPrice` больше не вызываются; обновляйте слушатели проекта и sPricing вместе с sCommerce. diff --git a/docs/uk/README.md b/docs/uk/README.md index cd53f50..f178978 100644 --- a/docs/uk/README.md +++ b/docs/uk/README.md @@ -64,6 +64,7 @@ php artisan migrate - [Початок роботи](./getting-started.md) - [Керування магазином](./admin/index.md) - [Розробнику](./developers/index.md) +- [Події валідації оформлення](developers/events.md#scommercecheckoutvalidationrules) - [API / sApi](./developers/api.md) - [CSV-імпорт та експорт](./admin/import-export.md) diff --git a/docs/uk/attributes/physical-properties.md b/docs/uk/attributes/physical-properties.md index 3f01883..b1095af 100644 --- a/docs/uk/attributes/physical-properties.md +++ b/docs/uk/attributes/physical-properties.md @@ -28,7 +28,8 @@ Копіювання товару переносить фізичні параметри звичайним копіюванням атрибутів, без статичних полів. Перед дублюванням менеджер зберігає джерельний товар, щоб перенести його додатні колонки. CSV використовує атрибути; старі вхідні ключі також -перетворюються на явний запис атрибута. Кошик і швидке замовлення використовують +перетворюються на явний запис атрибута. Кошик повертає налаштовані поля товару та атрибути для відображення, без +автоматичного додавання всіх фізичних параметрів. Швидке замовлення використовує `$product->physicalPayload()`. Вимірювання, яке неможливо прочитати, не потрапляє в рядок і записується в лог, а покупка не зривається. Інтеграції доставки ніколи не отримують вигаданого числа. diff --git a/docs/uk/developers/events.md b/docs/uk/developers/events.md index dc2c4ff..52021c4 100644 --- a/docs/uk/developers/events.md +++ b/docs/uk/developers/events.md @@ -190,27 +190,36 @@ Event::listen('sCommerce.ResolveProductDeposit', function (array $payload) { ### sCommerce.CheckoutValidationRules -Викликається в `sCheckout::getValidationRules(array $data)` після об’єднання базових правил із правилами вибраного способу доставки. +`getValidationRules(array $data, string $context = 'default')` — єдина точка +формування стандартних правил, правил доставки та плагінів. Звичайне +оформлення перевіряє користувача, доставку й оплату; швидке — кількість, +телефон та email. Вимкнені доставка/оплата не вимагають відповідного методу. +Обидва набори містять правила масиву товарів і цілої додатної кількості. -- `data` — вхідні дані, передані в `getValidationRules()`. -- `rules` — підсумковий масив правил за посиланням. Можна додавати й замінювати ключі або видаляти їх через `unset()`. - -Розмістіть слухач у `core/custom/packages/main/plugins/sCommerceEvents.php`, якщо service provider пакета main завантажує цю папку. Змінюйте `rules` безпосередньо: повернений масив не застосовується. Кожен наступний слухач бачить зміни попередніх і може їх перевизначити. +Laravel Event передає `data`, `rules` за посиланням та `context` +(типово `default`, для швидкого замовлення — `quick`). Змінюйте `rules` безпосередньо; повернення ігнорується. +Наступні слухачі бачать попередні зміни. Базовий набір обирається через switch; інші контексти отримують правила звичайного оформлення та доступні плагінам. ```php use Illuminate\Support\Facades\Event; -Event::listen('sCommerce.CheckoutValidationRules', function (array $params) { - $rules = &$params['rules']; - $data = $params['data']; - - $rules['user.email'] = 'nullable|email|max:255'; - $rules['user.first_name'] = ['required', 'string', 'max:100']; - unset($rules['user.middle_name']); +Event::listen('sCommerce.CheckoutValidationRules', function (array $params): void { + if ($params['context'] === 'default') $params['rules']['user.email'] = 'nullable|email'; + $params['rules']['products'][] = function ($field, $items, $fail) { + // Правила проєкту; $fail('Повідомлення') відхиляє оформлення. + }; }); ``` -`setOrderData()` потім залишає лише правила для ключів, наявних у переданих даних. Тому доданий `required` не робить відсутнє поле обов’язковим під час такого часткового оновлення. Видалення правила також виключає поле з цього шляху валідації, а не забезпечує збереження довільних полів. Щоб зберегти поля проєкту в `meta` замовлення, використовуйте [`sCommerce.BeforeOrderCreate`](#scommercebeforeordercreate). Якщо жоден рядок кошика не потребує доставки, `delivery.method` видаляється ще до цієї події. Швидке замовлення має власні правила та не викликає цей хук. +`setOrderData()` зберігає часткову валідацію лише переданих полів. +`processOrder()` перевіряє підготовлені серверні дані тим самим повним набором +правил перед записом і оплатою, зокрема за прямого виклику. Швидке оформлення +викликає валідатор і цю подію один раз після підготовки серверних позицій. +Кількість у введенні та позиціях залишається без приведення до int; перетворення, +сума окремого товару та заставна вартість розраховуються після успішної перевірки. +`products` із запиту ігнорується: використовуються фактичний товар або кошик. +Часткові форми без товарів не запускають правило для products. +Атрибута мінімуму та окремого класу політики проєкту в пакеті немає. Для цих трьох подій використовуйте точні назви з цієї сторінки, без префікса `evolution.`. Попередні цінові події `evolution.sCommerceResolveProductPriceMode` і `evolution.sCommerceResolveProductPrice` більше не викликаються; оновлюйте проєктні слухачі та sPricing разом із sCommerce. diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 1fc849f..79867d1 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -54,7 +54,7 @@ public function __construct() } /** - * Generate validation rules for the checkout process. + * Build standard checkout rules and let delivery methods and plugins customize them. * * This method dynamically generates validation rules for the checkout process * based on the provided order data. It includes base validation rules for user, @@ -62,10 +62,12 @@ public function __construct() * delivery method. When no cart line needs shipping, the delivery rules are dropped. * Plugins can then add, replace, or remove rules through * the sCommerce.CheckoutValidationRules event. Its payload contains - * the input data and the rules array passed by reference. + * the input data, rules array passed by reference and the checkout context. * * @param array $data The input data for the checkout process. * Example: ['delivery' => ['method' => 'courier', ...], ...]. + * @param string $context default uses the full form; quick uses quick-order input fields. + * Other contexts start with checkout rules and may be customized by plugins. * @return array An associative array of validation rules, where the key is the * field name, and the value is the validation rule. * @@ -81,46 +83,66 @@ public function __construct() * ... * ] */ - public function getValidationRules(array $data): array + public function getValidationRules(array $data, string $context = 'default'): array { - $deliveryMethods = array_keys($this->deliveryMethods); - $paymentMethods = array_keys($this->paymentMethods); - - $rules = [ - 'user.first_name' => 'required|string|max:255', - 'user.middle_name' => 'nullable|string|max:255', - 'user.last_name' => 'nullable|string|max:255', - 'user.email' => 'required|email|max:255', - 'user.phone' => 'nullable|string|max:20|regex:/^\+?[1-9]\d{7,14}$/', - 'user.vat' => 'sometimes|string|max:32', - 'user.address.country' => 'sometimes|string|max:255', - 'user.address.state' => 'sometimes|string|max:255', - 'user.address.city' => 'sometimes|string|max:255', - 'user.address.street' => 'sometimes|string|max:255', - 'user.address.zip' => 'sometimes|string|max:10', - 'delivery.method' => 'required|string|in:' . implode(',', $deliveryMethods), - 'payment.method' => 'required|string|in:' . implode(',', $paymentMethods), - 'comment' => 'nullable|string|max:1000', - 'do_not_call' => 'nullable|boolean', - 'delivery.courier.recipient_region' => 'sometimes|string|max:255', - 'delivery.courier.recipient_city' => 'sometimes|string|max:255', - 'delivery.courier.recipient_street' => 'sometimes|string|max:255', - 'delivery.courier.recipient_build' => 'sometimes|string|max:10', - 'delivery.courier.recipient_room' => 'sometimes|string|max:10', - 'delivery.courier.recipient_zip' => 'sometimes|string|max:10', - 'user.recipient_person' => 'nullable|string|max:255', - 'user.recipient_phone' => 'nullable|string|max:20|regex:/^\+?[1-9]\d{7,14}$/', - ]; + switch ($context) { + case 'quick': + $rules = [ + 'product' => 'nullable|integer|exists:products,id,' . ((int)($data['productId'] ?? 0) === 0 ? '0' : null), + 'quantity' => 'nullable|integer|min:1', + 'phone' => 'nullable|string|max:20', + 'email' => 'nullable|email|max:255', + ]; + break; + + case 'default': + default: + $deliveryMethods = array_keys($this->deliveryMethods); + $paymentMethods = array_keys($this->paymentMethods); + + $rules = [ + 'user.first_name' => 'required|string|max:255', + 'user.middle_name' => 'nullable|string|max:255', + 'user.last_name' => 'nullable|string|max:255', + 'user.email' => 'required|email|max:255', + 'user.phone' => 'nullable|string|max:20|regex:/^\+?[1-9]\d{7,14}$/', + 'user.vat' => 'sometimes|string|max:32', + 'user.address.country' => 'sometimes|string|max:255', + 'user.address.state' => 'sometimes|string|max:255', + 'user.address.city' => 'sometimes|string|max:255', + 'user.address.street' => 'sometimes|string|max:255', + 'user.address.zip' => 'sometimes|string|max:10', + 'delivery.method' => 'required|string|in:' . implode(',', $deliveryMethods), + 'payment.method' => 'required|string|in:' . implode(',', $paymentMethods), + 'comment' => 'nullable|string|max:1000', + 'do_not_call' => 'nullable|boolean', + 'delivery.courier.recipient_region' => 'sometimes|string|max:255', + 'delivery.courier.recipient_city' => 'sometimes|string|max:255', + 'delivery.courier.recipient_street' => 'sometimes|string|max:255', + 'delivery.courier.recipient_build' => 'sometimes|string|max:10', + 'delivery.courier.recipient_room' => 'sometimes|string|max:10', + 'delivery.courier.recipient_zip' => 'sometimes|string|max:10', + 'user.recipient_person' => 'nullable|string|max:255', + 'user.recipient_phone' => 'nullable|string|max:20|regex:/^\+?[1-9]\d{7,14}$/', + ]; + + if (!$this->requiresDelivery()) { + unset($rules['delivery.method']); + } elseif (!empty($data['delivery']['method']) && isset($this->deliveryMethods[$data['delivery']['method']])) { + $deliveryMethod = $this->deliveryMethods[$data['delivery']['method']]; + $rules = array_merge($rules, $deliveryMethod->getValidationRules()); + } - if (!$this->requiresDelivery()) { - unset($rules['delivery.method']); - } elseif (!empty($data['delivery']['method']) && isset($this->deliveryMethods[$data['delivery']['method']])) { - $deliveryMethod = $this->deliveryMethods[$data['delivery']['method']]; - $rules = array_merge($rules, $deliveryMethod->getValidationRules()); + if (sCommerce::config('basic.payments_on', 1) != 1) { + unset($rules['payment.method']); + } + break; } - Event::dispatch('sCommerce.CheckoutValidationRules', [['data' => $data, 'rules' => &$rules]]); + $rules['products'] = ['array']; + $rules['products.*.quantity'] = ['required', 'integer', 'min:1']; + Event::dispatch('sCommerce.CheckoutValidationRules', [['data' => $data, 'rules' => &$rules, 'context' => $context]]); return $rules; } @@ -452,7 +474,7 @@ public function setOrderData(array $data): array * Process the order and optionally trigger payment handling. * * Flow: - * 1. Resolve the trusted checkout context; a refused context stops before any side effect. + * 1. Validate the order with getValidationRules(), then resolve the trusted checkout context. * 2. Validate delivery/payment methods if they are enabled. A cart without shipped lines * needs no delivery method and stores `delivery_info = {method: '', required: false, cost: 0}`. * 3. Save the order to the database. @@ -478,6 +500,7 @@ public function processOrder(): array if (empty($this->orderData['products'])) { throw new \Exception("Invalid products in Order."); } + Validator::make($this->orderData, $this->getValidationRules($this->orderData))->validate(); $this->checkoutContext = CheckoutContext::resolve($this->orderData, 'storefront', self::trustedActorId()); @@ -615,10 +638,12 @@ public function processOrder(): array } /** - * Process the quick order. + * Process a quick order with one validation of input and prepared product lines. * * This method validates the input data, creates a new order for a quick purchase, * and saves it in the database. If product is 0, it will fetch products from the cart. + * Quantities remain uncast for validation; integer conversion and single-product totals + * (including container deposits) are calculated only after the validator accepts them. * The trusted checkout context is resolved before the order is built. * * @param array $data The data from the request, including productId, optionId, quantity, exchange (empty returnable @@ -627,22 +652,8 @@ public function processOrder(): array */ public static function quickOrder(array $data) { - $validator = Validator::make($data, [ - 'product' => 'nullable|integer|exists:products,id,' . ((int)$data['productId'] == 0 ? '0' : null), - 'quantity' => 'nullable|integer|min:1', - 'phone' => 'nullable|string|max:20', - 'email' => 'nullable|email|max:255', - ]); - - if ($validator->fails()) { - return [ - 'success' => false, - 'product' => (int)$data['productId'], - 'message' => 'Invalid data provided.', - 'errors' => $validator->errors(), - ]; - } - + // Product lines are built by the server below, never accepted from the request. + unset($data['products']); $product = sCommerce::getProduct($data['productId']); $userId = evo()->getLoginUserID('web') ?: evo()->getLoginUserID('mgr'); // Checking if the user is authorized $user = evo()->getUserInfo($userId ?: 0) ?: []; @@ -702,11 +713,9 @@ public static function quickOrder(array $data) ]; } - $quantity = isset($data['quantity']) && $data['quantity'] > 0 ? (int) $data['quantity'] : 1; + $quantity = $data['quantity'] ?? 1; $optionId = max(0, (int)($data['optionId'] ?? 0)); $pricing = static::resolveProductPricing($product, $optionId); - $price = (float)$pricing['priceAsFloat']; - $cost = $price * $quantity; $productData = [ 'id' => $product->id, @@ -727,14 +736,28 @@ public static function quickOrder(array $data) $productData['priceMode'] = $pricing['priceMode']; } $productData = array_merge($productData, app(OrderLineIdentity::class)->snapshot($product, $optionId)); - // Units exchanged for empty returnable containers carry no new deposit. - $productData = array_merge($productData, ContainerDeposit::lineFields(ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)))); - $cost += (float)($productData['deposit_amount'] ?? 0); - $productData = array_merge($productData, $product->physicalPayload()); $productsData = [$productData]; } + $validationData = array_replace($data, ['products' => $productsData, 'user' => $userData]); + $validator = Validator::make($validationData, app(static::class)->getValidationRules($validationData, 'quick')); + if ($validator->fails()) { + return ['success' => false, 'message' => $validator->errors()->first(), + 'errors' => $validator->errors(), 'error_code' => 'checkout_validation']; + } + + if (!empty($data['productId']) && $data['productId'] != 0) { + $quantity = (int) $quantity; + $productsData[0]['quantity'] = $quantity; + $cost = (float) $pricing['priceAsFloat'] * $quantity; + // Calculate deposit amounts only after the raw quantity has passed validation. + $productsData[0] = array_merge($productsData[0], ContainerDeposit::lineFields( + ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)) + )); + $cost += (float) ($productsData[0]['deposit_amount'] ?? 0); + } + do { $identifier = Str::random(rand(32, 64)); } while (sOrder::where('identifier', $identifier)->exists()); diff --git a/tests/standalone/README.md b/tests/standalone/README.md index 54702f7..90f6318 100644 --- a/tests/standalone/README.md +++ b/tests/standalone/README.md @@ -46,3 +46,7 @@ The new-order checks cover draft initialization and rendering, not order creatio Physical attribute checks run the actual fresh installation and additive upgrade migration, verify positive legacy values are consumed and zeroed atomically on save, and exercise decimal attribute, copy and delivery flows. + +```bash +php tests/standalone/scommerce-checkout-validation.php +``` diff --git a/tests/standalone/fixtures/checkout-harness.php b/tests/standalone/fixtures/checkout-harness.php index 3b53361..6e61fd6 100644 --- a/tests/standalone/fixtures/checkout-harness.php +++ b/tests/standalone/fixtures/checkout-harness.php @@ -38,6 +38,7 @@ final class FakeProduct public float $oldPrice = 0.0; public float $deposit = 0.0; public string $currency = 'EUR'; + public array $attributes = []; public function __construct(public int $id, public string $sku, public float $price = 10.0, public int $mode = sProduct::MODE_SIMPLE, public ?string $gtin = null, public int $inventory = 100, public string $title = '') @@ -57,7 +58,15 @@ public function physicalPayload(): array { return ['weight' => 125, 'width' => 0, 'height' => 0, 'length' => 0, 'volume' => 0]; } - public function attribute(string $alias) { return null; } + /** + * Provide synthetic attributes without requiring a product database. + * + * @param string $alias Requested product attribute. + * @return object|null Synthetic attribute value or an absent attribute. + */ + public function attribute(string $alias) { + return array_key_exists($alias, $this->attributes) ? (object) ['type' => 0, 'value' => $this->attributes[$alias]] : null; + } public function toArray(): array { return get_object_vars($this); } public function fulfilment(int $optionId = 0): array { @@ -220,7 +229,7 @@ public function generate(?int $orderId = null): string { return ''; } 'products' => $products, 'cost' => $cost, 'currency' => 'EUR', - 'user' => ['id' => 0, 'email' => 'customer@shop.test'], + 'user' => ['id' => 0, 'first_name' => 'Customer', 'email' => 'customer@shop.test'], 'payment' => ['method' => 'card'], ], $extra); }, $checkout, sCheckout::class)(); @@ -231,4 +240,6 @@ public function generate(?int $orderId = null): string { return ''; } $_SESSION['sCart'] = $cart; return (new sCart())->getMiniCart()['items']; }; +// The provider registers checkout as a singleton; final quick validation reuses it. +$app->singleton(sCheckout::class, static fn () => (new ReflectionClass(sCheckout::class))->newInstanceWithoutConstructor()); $_SESSION = []; diff --git a/tests/standalone/scommerce-checkout-validation.php b/tests/standalone/scommerce-checkout-validation.php new file mode 100644 index 0000000..482a440 --- /dev/null +++ b/tests/standalone/scommerce-checkout-validation.php @@ -0,0 +1,74 @@ +products = [1 => $product]; +$shop->config['basic.deliveries_on'] = 0; +$shop->config['basic.payments_on'] = 0; +$instance = $checkout([['id' => 1, 'quantity' => 1, 'priceAsFloat' => 10]]); +$baseRules = $instance->getValidationRules([]); +$check(isset($baseRules['user.first_name']), 'Default checkout rules are preserved'); +$check($baseRules === $instance->getValidationRules([], 'default'), 'Explicit default context matches the omitted argument'); +$quickRules = $instance->getValidationRules([], 'quick'); +$check(isset($quickRules['quantity'], $quickRules['phone'], $quickRules['email']) && !isset($quickRules['user.first_name']), 'Quick context selects quick input rules'); +$before = sOrder::count(); +$result = $instance->processOrder(); +$check($result['success'] && sOrder::count() === $before + 1, 'Normal checkout accepts valid standard fields without plugins'); +$result = sCheckout::quickOrder(['productId' => 1, 'phone' => '123456789']); +$check($result['success'] && $result['order']->products[0]['quantity'] === 1 && (float) $result['order']->cost === 10.0, 'Omitted quick quantity defaults to one'); + +$seen = []; +Event::listen('sCommerce.CheckoutValidationRules', static function (array $params) use (&$seen): void { + $seen[] = $params; + $params['rules']['products'][] = static function ($field, $items, $fail): void { + foreach ($items as $item) { + if (($item['quantity'] ?? 0) < 3) $fail('Project quantity policy'); + } + }; +}); +$rules = $instance->getValidationRules(['user' => ['email' => 'test@shop.test']]); +$check(isset($rules['user.first_name'], $rules['user.email']), 'Plugins extend the standard rule set'); +$check($instance->setOrderData(['comment' => 'Partial update'])['success'], 'Partial form validation ignores absent product lines'); +$before = sOrder::count(); +$charges = count(RecordingPayment::$charges); +$result = $checkout([['id' => 1, 'quantity' => 1, 'priceAsFloat' => 10]])->processOrder(); +$check(!$result['success'] && str_contains($result['message'], 'Project quantity policy'), 'Direct processOrder validates plugin rules'); +$check(sOrder::count() === $before && count(RecordingPayment::$charges) === $charges, 'Failure precedes order and payment'); +$result = sCheckout::quickOrder(['productId' => 1, 'quantity' => 1, 'phone' => '123456789', 'products' => [['id' => 1, 'quantity' => 999]]]); +$check(!$result['success'] && $result['message'] === 'Project quantity policy', 'Quick rules use prepared products instead of forged input'); +$check(sOrder::count() === $before, 'Invalid quick order is not persisted'); +$result = sCheckout::quickOrder(['productId' => 1, 'quantity' => 3, 'phone' => '123456789']); +$check($result['success'] && sOrder::count() === $before + 1, 'Valid quick order succeeds'); +$result = $checkout([['id' => 1, 'quantity' => 3, 'priceAsFloat' => 10]])->processOrder(); +$check($result['success'] && sOrder::count() === $before + 2, 'Valid normal order succeeds'); +$check(in_array('quick', array_column($seen, 'context'), true) && in_array('default', array_column($seen, 'context'), true), 'Both checkout types share the same rule event'); +$before = sOrder::count(); +$result = $checkout([['id' => 1, 'quantity' => 3, 'priceAsFloat' => 10]], ['user' => ['first_name' => '']])->processOrder(); +$check(!$result['success'] && sOrder::count() === $before, 'Commerce standard rules also run before persistence'); +// Every quick request must dispatch the rules once, with raw quantities visible to plugins. +foreach (['1.5', 1.5, 'abc', [], 0, -1, '999999999999999999999999'] as $rawQuantity) { + $seenBefore = count($seen); + $ordersBefore = sOrder::count(); + $result = sCheckout::quickOrder(['productId' => 1, 'quantity' => $rawQuantity, 'phone' => '123456789']); + $check(!$result['success'] && sOrder::count() === $ordersBefore, 'Invalid raw quantity cannot create a quick order'); + $check(count($seen) === $seenBefore + 1, 'Quick order invokes the rule event once'); + $payload = $seen[array_key_last($seen)]; + $check($payload['data']['quantity'] === $rawQuantity && $payload['data']['products'][0]['quantity'] === $rawQuantity, 'Both input and prepared line retain the raw quantity'); +} +$seenBefore = count($seen); +$result = sCheckout::quickOrder(['productId' => 1, 'quantity' => '3', 'phone' => '123456789']); +$check($result['success'] && count($seen) === $seenBefore + 1, 'Valid string quantity is validated once'); +$check($result['order']->products[0]['quantity'] === 3 && (float) $result['order']->cost === 30.0, 'Accepted quantity becomes integer before totals and persistence'); +$_SESSION['sCart'] = [1 => [0 => 3]]; +$seenBefore = count($seen); +$result = sCheckout::quickOrder(['productId' => 0, 'phone' => '123456789']); +$check($result['success'] && count($seen) === $seenBefore + 1, 'Cart quick checkout also validates once'); +$check($result['order']->products[0]['quantity'] === 3 && (float) $result['order']->cost === 30.0, 'Cart quick checkout preserves server quantities and total'); + +echo "OK: {$checks} checkout validation rule checks; isolated SQLite.\n"; From 13356c608ba1ecddf1b07ca6da56100ae42d8b1d Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Tue, 27 Oct 2026 01:46:28 +0200 Subject: [PATCH 3/3] fix(tests): initialize validation in payment schedule fixture --- tests/standalone/scommerce-payment-schedule.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/standalone/scommerce-payment-schedule.php b/tests/standalone/scommerce-payment-schedule.php index e95b40b..92c9dcb 100644 --- a/tests/standalone/scommerce-payment-schedule.php +++ b/tests/standalone/scommerce-payment-schedule.php @@ -70,6 +70,7 @@ public function __call($level, $arguments) { $this->records[] = [$level, $argume $loader = new ArrayLoader(); $loader->addMessages('en', 'global', require $package . '/lang/en/global.php', 'sCommerce'); $app->instance('translator', new Translator($loader, 'en')); +$app->instance('validator', new Illuminate\Validation\Factory($app['translator'], $app)); $app->instance('sCommerce', new class { public function config($key, $default = null) { return match ($key) { 'basic.deliveries_on' => 0, 'basic.payments_on' => 1, default => str_starts_with($key, 'notifications.') ? 0 : $default }; } }); @@ -146,7 +147,7 @@ public function generate(?int $orderId = null): string { return ''; } 'products' => [['id' => 1, 'title' => 'Service', 'quantity' => 1, 'price' => $cost]], 'cost' => $cost, 'currency' => 'EUR', - 'user' => ['id' => 0, 'email' => ''], + 'user' => ['id' => 0, 'first_name' => 'Customer', 'email' => 'customer@shop.test'], 'payment' => ['method' => $method], ]; }, $checkout, sCheckout::class)();