Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions plugins/sCommercePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/Checkout/sCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions tests/standalone/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
40 changes: 40 additions & 0 deletions tests/standalone/fixtures/piece-cart-master.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/standalone/scommerce-checkout-validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/standalone/scommerce-measured-quantities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
92 changes: 92 additions & 0 deletions tests/standalone/scommerce-not-found-request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
/** Exercise the registered plugin listener without booting a site or database. */
namespace Illuminate\Support\Facades {
/** Capture plugin registration without loading Laravel events. @since 1.5.0 */
class Event {
public static array $listeners = [];
/** Store listeners so scenarios can invoke the real closures. @since 1.5.0 */
public static function listen($name, $listener) { self::$listeners[$name] = $listener; }
}
}
namespace Seiger\sCommerce\Facades {
/** Supply isolated catalogue hits and misses. @since 1.5.0 */
class sCommerce {
/** Resolve the known product route through the cached listing. @since 1.5.0 */
public static function documentListing() { return ['catalog/tour' => 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";
}