diff --git a/plugins/sCommercePlugin.php b/plugins/sCommercePlugin.php index 1bbb36d..e24afb2 100644 --- a/plugins/sCommercePlugin.php +++ b/plugins/sCommercePlugin.php @@ -10,11 +10,27 @@ use Seiger\sCommerce\Facades\sFilter; /** - * Catch the Product by alias + * Resolve storefront product aliases only when an HTTP request is available. + * + * Legacy Manager/API entrypoints can dispatch OnPageNotFound before binding + * the request service. Their error handling must not enter storefront routing. */ Event::listen('evolution.OnPageNotFound', function() { + if ( + (defined('IN_MANAGER_MODE') && IN_MANAGER_MODE) + || (defined('EVO_API_MODE') && EVO_API_MODE) + || !app()->bound('request') + ) { + return; + } + + // An internal forward has already resolved this request. + if (evo()->documentMethod === 'id') { + return; + } + $aliasArr = request()->segments(); - if ($aliasArr[0] === evo()->getConfig('lang', 'base')) { + if (($aliasArr[0] ?? null) === evo()->getConfig('lang', 'base')) { unset($aliasArr[0]); } $alias = implode('/', $aliasArr); @@ -63,9 +79,18 @@ }); /** - * Get document fields and add to array of resource fields + * Project product fields onto storefront documents while preserving error responses. + * + * A stale product placeholder must not replace an internally forwarded 404/401 page. */ Event::listen('evolution.OnBeforeLoadDocumentObject', function($params) { + // Never replace an error response with the product that failed to render. + if (evo()->documentMethod === 'id' && in_array((int)evo()->documentIdentifier, [ + (int)evo()->getConfig('error_page'), (int)evo()->getConfig('unauthorized_page'), + ], true)) { + return; + } + $requestId = (int)evo()->getPlaceholder('product'); if ($requestId) { diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 27851b0..d73fa0a 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -802,6 +802,8 @@ public static function quickOrder(array $data) } elseif ($quantity > $rules->max) { return ['success' => false, 'message' => __('sCommerce::global.quantity_too_large', ['max' => $rules->max]), 'error_code' => 'checkout_validation']; + } else { + $quantity = (int) $quantity; } if (($shortages = sCart::shortages([$productId => [$optionId => $quantity]])) !== []) { return self::outOfStock($shortages); diff --git a/tests/standalone/README.md b/tests/standalone/README.md index 331d98c..3f13df3 100644 --- a/tests/standalone/README.md +++ b/tests/standalone/README.md @@ -1,5 +1,14 @@ # Isolated sCommerce regression checks +The not-found listener checks need only PHP, without CMS dependencies. Run each +scenario in a separate process because Manager/API mode constants are immutable: + +```sh +for scenario in missing manager api product language empty unknown forwarded error-page unauthorized-page; do + php tests/standalone/scommerce-not-found-request.php "$scenario" || exit 1 +done +``` + Run from the package root with PHP 8.4, SQLite and the dependencies of a compatible Evolution CMS installation. The CMS is not bootstrapped; database checks use in-memory SQLite. diff --git a/tests/standalone/fixtures/piece-cart-master.json b/tests/standalone/fixtures/piece-cart-master.json index df42192..330a249 100644 --- a/tests/standalone/fixtures/piece-cart-master.json +++ b/tests/standalone/fixtures/piece-cart-master.json @@ -27,6 +27,11 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -76,6 +81,11 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -125,6 +135,11 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -174,6 +189,11 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, @@ -226,6 +246,11 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -252,6 +277,11 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -278,6 +308,11 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -304,6 +339,11 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, + "weight": 125, + "width": 0, + "height": 0, + "length": 0, + "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, diff --git a/tests/standalone/scommerce-checkout-validation.php b/tests/standalone/scommerce-checkout-validation.php index e177c24..6bcf1ac 100644 --- a/tests/standalone/scommerce-checkout-validation.php +++ b/tests/standalone/scommerce-checkout-validation.php @@ -64,6 +64,8 @@ $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'); +$payload = $seen[array_key_last($seen)]; +$check($payload['data']['quantity'] === '3' && $payload['data']['products'][0]['quantity'] === '3', 'Valid string quantity remains raw for validation plugins'); $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]]; // Product quick orders resolve the cart facade for stock checks; a request starts with the session cart. diff --git a/tests/standalone/scommerce-measured-quantities.php b/tests/standalone/scommerce-measured-quantities.php index f09c5b0..cb41262 100644 --- a/tests/standalone/scommerce-measured-quantities.php +++ b/tests/standalone/scommerce-measured-quantities.php @@ -257,7 +257,7 @@ function config($key = null, $default = null) { return app('config')->get($key, $check(!array_intersect_key($byId[11], array_flip(array_merge(MeasuredQuantity::LINE_KEYS, MeasuredQuantity::DISPLAY_KEYS))), 'Piece lines have no measured keys'); $check($byId[13]['quantity'] === 2400 && $byId[13]['sumAsFloat'] === 23.76 && !isset($byId[13]['line_weight']) && $mini['totalSum'] === 3.04 + 25.0 + 23.76, 'Volume line without weight; totals add up'); $capture = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/fixtures/piece-cart-capture.php') . ' ' . escapeshellarg($package)); -$check($capture === str_replace("\r\n", "\n", file_get_contents(__DIR__ . '/fixtures/piece-cart-master.json')), 'Piece cart and order JSON are byte-identical to master (normalized line endings)'); +$check($capture === str_replace("\r\n", "\n", file_get_contents(__DIR__ . '/fixtures/piece-cart-master.json')), 'Piece cart and order JSON match the established payload including physical fields (normalized line endings)'); // 10. Quick order and checkout keep the snapshot keys and the exact total. $quick = sCheckout::quickOrder(['productId' => 10, 'amount' => '2.53', 'email' => 'quick@shop.test']); diff --git a/tests/standalone/scommerce-not-found-request.php b/tests/standalone/scommerce-not-found-request.php new file mode 100644 index 0000000..e329036 --- /dev/null +++ b/tests/standalone/scommerce-not-found-request.php @@ -0,0 +1,92 @@ + 42]; } + /** Return a miss for unknown aliases. @since 1.5.0 */ + public static function getProductByAlias($alias) { return null; } + } + /** Avoid a real filter service when an alias is absent. @since 1.5.0 */ + class sFilter { + /** Simulate an unmatched filter URL. @since 1.5.0 */ + public static function validateFilters() { return null; } + } +} +namespace { + /** Provide only the array operations used by alias resolution. @since 1.5.0 */ + class Arr { + /** Test a cached alias key. @since 1.5.0 */ + public static function exists($items, $key) { return array_key_exists($key, $items); } + /** Return the final route segment. @since 1.5.0 */ + public static function last($items) { return end($items); } + } + $mode = $argv[1] ?? 'missing'; + define('IN_MANAGER_MODE', $mode === 'manager'); + define('EVO_API_MODE', $mode === 'api'); + $requestCalls = 0; + $segments = match ($mode) { + 'product' => ['catalog', 'tour'], + 'language' => ['en', 'catalog', 'tour'], + 'empty' => [], + default => ['unknown'], + }; + $site = new class { + public array $placeholders = []; + public string $documentMethod = 'alias'; + public int $documentIdentifier = 1; + /** Supply language, home and distinct error resource IDs. @since 1.5.0 */ + public function getConfig($key, $default = null) { return match ($key) { 'lang' => 'en', 'error_page' => 60, 'unauthorized_page' => 61, default => $default }; } + /** Capture the product selected by the real listener. @since 1.5.0 */ + public function setPlaceholder($key, $value) { $this->placeholders[$key] = $value; } + /** Stop before exit and expose the selected forward target. @since 1.5.0 */ + public function sendForward($id) { throw new \RuntimeException('forward:' . $id); } + }; + /** Provide request-binding availability without booting a CMS. @since 1.5.0 */ + function app() { return new class { + /** Omit the binding in the legacy bootstrap regression. @since 1.5.0 */ + public function bound($key) { return $GLOBALS['mode'] !== 'missing'; } + }; } + /** Fail if a guarded mode tries resolving the HTTP request. @since 1.5.0 */ + function request() { + ++$GLOBALS['requestCalls']; + if (in_array($GLOBALS['mode'], ['missing', 'manager', 'api'], true)) { + throw new \LogicException('Unexpected request resolution'); + } + return new class { /** Supply route segments without a request object. @since 1.5.0 */ public function segments() { return $GLOBALS['segments']; } }; + } + /** Return the isolated CMS double. @since 1.5.0 */ + function evo() { return $GLOBALS['site']; } + set_error_handler(function ($severity, $message) { throw new \ErrorException($message, 0, $severity); }); + require dirname(__DIR__, 2) . '/plugins/sCommercePlugin.php'; + if ($mode === 'forwarded') { $site->documentMethod = 'id'; } + if (in_array($mode, ['error-page', 'unauthorized-page'], true)) { + $site->documentMethod = 'id'; + $site->documentIdentifier = $mode === 'error-page' ? 60 : 61; + $site->placeholders['product'] = 42; + \Illuminate\Support\Facades\Event::$listeners['evolution.OnBeforeLoadDocumentObject']([]); + echo "PASS $mode\n"; + exit; + } + $forward = null; + try { + \Illuminate\Support\Facades\Event::$listeners['evolution.OnPageNotFound'](); + } catch (\RuntimeException $exception) { + $forward = $exception->getMessage(); + } + $skip = in_array($mode, ['missing', 'manager', 'api', 'forwarded'], true); + if ($requestCalls !== ($skip ? 0 : 1)) { throw new \LogicException('Incorrect request access'); } + $product = in_array($mode, ['product', 'language'], true); + if ($forward !== ($product ? 'forward:1' : null)) { throw new \LogicException('Incorrect routing'); } + if ($site->placeholders !== ($product ? ['product' => 42] : [])) { throw new \LogicException('Incorrect product'); } + echo "PASS $mode\n"; +}