diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 0a93c8e1..756f3837 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -225,6 +225,18 @@ $_lang['ms3_err_product_id_required'] = 'Product ID is required'; $_lang['ms3_err_product_nf'] = 'Product not found'; $_lang['ms3_err_product_update_failed'] = 'Failed to update product'; +$_lang['ms3_err_catalog_parents_invalid'] = 'Invalid parents filter'; +$_lang['ms3_err_catalog_parents_limit'] = 'Too many parent category IDs'; +$_lang['ms3_err_catalog_price_invalid'] = 'Invalid price filter'; +$_lang['ms3_err_catalog_price_range'] = 'price_max must be greater than or equal to price_min'; +$_lang['ms3_err_catalog_stock_invalid'] = 'Invalid stock_min filter'; +$_lang['ms3_err_catalog_vendor_invalid'] = 'Invalid vendor_id filter'; +$_lang['ms3_err_catalog_vendor_limit'] = 'Too many vendor IDs'; +$_lang['ms3_err_catalog_options_json'] = 'options must be a JSON object or map'; +$_lang['ms3_err_catalog_options_limit'] = 'Too many option filters or values'; +$_lang['ms3_err_catalog_option_key_invalid'] = 'Invalid option key'; +$_lang['ms3_err_catalog_option_value_invalid'] = 'Invalid option value'; +$_lang['ms3_err_catalog_option_unknown'] = 'Unknown option key'; $_lang['ms3_category_products_reordered'] = 'Products reordered successfully'; $_lang['ms3_category_product_published'] = 'Product published'; $_lang['ms3_category_product_unpublished'] = 'Product unpublished'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index a2e91fd9..26ab5683 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -225,6 +225,18 @@ $_lang['ms3_err_product_id_required'] = 'Не указан ID товара'; $_lang['ms3_err_product_nf'] = 'Товар не найден'; $_lang['ms3_err_product_update_failed'] = 'Не удалось обновить товар'; +$_lang['ms3_err_catalog_parents_invalid'] = 'Некорректный фильтр parents'; +$_lang['ms3_err_catalog_parents_limit'] = 'Слишком много ID категорий в parents'; +$_lang['ms3_err_catalog_price_invalid'] = 'Некорректный фильтр цены'; +$_lang['ms3_err_catalog_price_range'] = 'price_max должен быть не меньше price_min'; +$_lang['ms3_err_catalog_stock_invalid'] = 'Некорректный фильтр stock_min'; +$_lang['ms3_err_catalog_vendor_invalid'] = 'Некорректный фильтр vendor_id'; +$_lang['ms3_err_catalog_vendor_limit'] = 'Слишком много ID вендоров'; +$_lang['ms3_err_catalog_options_json'] = 'options должен быть JSON-объектом или картой'; +$_lang['ms3_err_catalog_options_limit'] = 'Слишком много ключей или значений опций'; +$_lang['ms3_err_catalog_option_key_invalid'] = 'Некорректный ключ опции'; +$_lang['ms3_err_catalog_option_value_invalid'] = 'Некорректное значение опции'; +$_lang['ms3_err_catalog_option_unknown'] = 'Неизвестный ключ опции'; $_lang['ms3_category_products_reordered'] = 'Порядок товаров успешно изменён'; $_lang['ms3_category_product_published'] = 'Товар опубликован'; $_lang['ms3_category_product_unpublished'] = 'Товар снят с публикации'; diff --git a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php index 8c82e809..f67db798 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php @@ -6,6 +6,7 @@ use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; +use MiniShop3\Services\Product\ProductCatalogFilterException; use MiniShop3\Services\Product\ProductCatalogService; use MODX\Revolution\modX; @@ -55,14 +56,22 @@ public function get(array $params = []): Response /** * GET /api/v1/product/list * - * Query: parent|category, limit, offset|page, sort, dir, query, - * context, include_options, include_content + * Query: parent|category, parents, nested, price_min, price_max, in_stock, stock_min, + * vendor_id, new, popular, favorite, options (JSON), + * limit, offset|page, sort, dir, query, context, include_options, include_content * * @param array $params Route + query params (Router merges $_GET) */ public function getList(array $params = []): Response { - $result = $this->catalog()->getList($params); + try { + $result = $this->catalog()->getList($params); + } catch (ProductCatalogFilterException $e) { + return Response::error( + $this->modx->lexicon($e->getLexiconKey()), + HttpStatus::BAD_REQUEST + ); + } return Response::success($result); } diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php new file mode 100644 index 00000000..70569169 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php @@ -0,0 +1,138 @@ +applyCategoryScope($query, $filters); + $this->applyDataFilters($query, $filters); + $this->applyOptionFilters($query, $filters, $dedupeRows); + } + + private function applyCategoryScope(xPDOQuery $query, ProductCatalogFilterSpec $filters): void + { + if (!$filters->hasParents()) { + return; + } + + $depth = $filters->nested ? self::NESTED_DEPTH : 0; + $parentsCsv = implode(',', $filters->parentIds); + $categoryIds = $this->scope->resolveCategoryIdsFromParents($parentsCsv, $depth); + + if ($categoryIds === []) { + // Force empty result set without raw SQL. + $query->where(['msProduct.id' => 0]); + + return; + } + + $this->scope->applyProductCategoryScope($query, $categoryIds); + } + + private function applyDataFilters(xPDOQuery $query, ProductCatalogFilterSpec $filters): void + { + if ($filters->priceMin !== null) { + $query->where(['Data.price:>=' => $filters->priceMin]); + } + if ($filters->priceMax !== null) { + $query->where(['Data.price:<=' => $filters->priceMax]); + } + + if ($filters->inStock) { + $query->where(['Data.stock:>' => 0]); + } + if ($filters->stockMin !== null) { + $query->where(['Data.stock:>=' => $filters->stockMin]); + } + + if ($filters->vendorIds !== []) { + $query->where(['Data.vendor_id:IN' => $filters->vendorIds]); + } + + if ($filters->flagNew) { + $query->where(['Data.new' => 1]); + } + if ($filters->flagPopular) { + $query->where(['Data.popular' => 1]); + } + if ($filters->flagFavorite) { + $query->where(['Data.favorite' => 1]); + } + } + + private function applyOptionFilters( + xPDOQuery $query, + ProductCatalogFilterSpec $filters, + bool $dedupeRows, + ): void { + if ($filters->options === []) { + return; + } + + $this->assertOptionKeysExist(array_keys($filters->options)); + + $index = 0; + foreach ($filters->options as $key => $values) { + $alias = 'OptFilter' . $index++; + + // Key is validated as [a-zA-Z0-9_]+ and exists in msOption. + $query->innerJoin( + msProductOption::class, + $alias, + "`{$alias}`.product_id = Data.id AND `{$alias}`.`key` = " . $this->modx->quote($key) + ); + $query->where(["{$alias}.value:IN" => $values]); + } + + // Multi-value option rows duplicate product rows on list pages. + // Count uses COUNT(DISTINCT) without GROUP BY (GROUP BY would break fetchColumn total). + if ($dedupeRows) { + $query->groupby('msProduct.id'); + } + } + + /** + * @param list $keys + */ + private function assertOptionKeysExist(array $keys): void + { + $c = $this->modx->newQuery(msOption::class); + $c->where(['key:IN' => $keys]); + $c->select('key'); + + if (!$c->prepare() || !$c->stmt->execute()) { + throw new \RuntimeException('Failed to validate catalog option keys'); + } + + $found = array_map('strval', $c->stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []); + if (array_diff($keys, $found) !== []) { + throw new ProductCatalogFilterException('ms3_err_catalog_option_unknown'); + } + } +} diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterException.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterException.php new file mode 100644 index 00000000..425178f3 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterException.php @@ -0,0 +1,22 @@ +lexiconKey; + } +} diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterParser.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterParser.php new file mode 100644 index 00000000..afa197f6 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterParser.php @@ -0,0 +1,261 @@ + $params + */ + public static function parse(array $params): ProductCatalogFilterSpec + { + [$priceMin, $priceMax] = self::parsePriceRange($params); + + return new ProductCatalogFilterSpec( + parentIds: self::parseParents($params), + nested: ProductCatalogService::toBool($params['nested'] ?? false), + priceMin: $priceMin, + priceMax: $priceMax, + inStock: ProductCatalogService::toBool($params['in_stock'] ?? false), + stockMin: self::parseStockMin($params), + vendorIds: self::parseVendorIds($params), + flagNew: ProductCatalogService::toBool($params['new'] ?? false), + flagPopular: ProductCatalogService::toBool($params['popular'] ?? false), + flagFavorite: ProductCatalogService::toBool($params['favorite'] ?? false), + options: self::parseOptions($params), + ); + } + + /** + * @param array $params + * @return list + */ + public static function parseParents(array $params): array + { + return self::parsePositiveIdList( + $params, + 'parents', + '/^-?\d+$/', + 'ms3_err_catalog_parents_invalid', + 'ms3_err_catalog_parents_limit', + self::MAX_PARENT_IDS, + true, + ); + } + + /** + * @param array $params + * @return array{0: ?float, 1: ?float} + */ + public static function parsePriceRange(array $params): array + { + $priceMin = self::parseOptionalFloat($params, 'price_min', 'ms3_err_catalog_price_invalid'); + $priceMax = self::parseOptionalFloat($params, 'price_max', 'ms3_err_catalog_price_invalid'); + + if (($priceMin !== null && $priceMin < 0) || ($priceMax !== null && $priceMax < 0)) { + throw new ProductCatalogFilterException('ms3_err_catalog_price_invalid'); + } + if ($priceMin !== null && $priceMax !== null && $priceMax < $priceMin) { + throw new ProductCatalogFilterException('ms3_err_catalog_price_range'); + } + + return [$priceMin, $priceMax]; + } + + /** + * @param array $params + */ + public static function parseStockMin(array $params): ?int + { + if (!array_key_exists('stock_min', $params) || $params['stock_min'] === '' || $params['stock_min'] === null) { + return null; + } + + if (!is_numeric($params['stock_min'])) { + throw new ProductCatalogFilterException('ms3_err_catalog_stock_invalid'); + } + + $value = (int) $params['stock_min']; + if ($value < 0) { + throw new ProductCatalogFilterException('ms3_err_catalog_stock_invalid'); + } + + return $value; + } + + /** + * @param array $params + * @return list + */ + public static function parseVendorIds(array $params): array + { + return self::parsePositiveIdList( + $params, + 'vendor_id', + '/^\d+$/', + 'ms3_err_catalog_vendor_invalid', + 'ms3_err_catalog_vendor_limit', + self::MAX_VENDOR_IDS, + false, + ); + } + + /** + * @param array $params + * @return array> + */ + public static function parseOptions(array $params): array + { + if (!array_key_exists('options', $params)) { + return []; + } + + $raw = $params['options']; + if (is_string($raw)) { + $trimmed = trim($raw); + if ($trimmed === '' || $trimmed === '{}') { + return []; + } + $decoded = json_decode($trimmed, true); + if (!is_array($decoded)) { + throw new ProductCatalogFilterException('ms3_err_catalog_options_json'); + } + $raw = $decoded; + } + + if (!is_array($raw)) { + throw new ProductCatalogFilterException('ms3_err_catalog_options_json'); + } + + if ($raw === []) { + return []; + } + + if (count($raw) > self::MAX_OPTION_KEYS) { + throw new ProductCatalogFilterException('ms3_err_catalog_options_limit'); + } + + $result = []; + foreach ($raw as $key => $value) { + if (!is_string($key) || $key === '' || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) { + throw new ProductCatalogFilterException('ms3_err_catalog_option_key_invalid'); + } + + $values = self::normalizeOptionValues($value); + if ($values === []) { + continue; + } + if (count($values) > self::MAX_OPTION_VALUES) { + throw new ProductCatalogFilterException('ms3_err_catalog_options_limit'); + } + $result[$key] = $values; + } + + return $result; + } + + /** + * CSV or array of ints; keeps only id > 0. + * When $rejectEmptyAfterTokens is true (parents), tokens that all discard → 400 + * so we never silently drop an explicit category filter. + * + * @param array $params + * @return list + */ + private static function parsePositiveIdList( + array $params, + string $key, + string $pattern, + string $invalidKey, + string $limitKey, + int $max, + bool $rejectEmptyAfterTokens, + ): array { + if (!array_key_exists($key, $params)) { + return []; + } + + $ids = []; + $sawToken = false; + foreach (self::asList($params[$key]) as $part) { + if (is_array($part)) { + throw new ProductCatalogFilterException($invalidKey); + } + $trimmed = trim((string) $part); + if ($trimmed === '' || $trimmed === '0') { + continue; + } + $sawToken = true; + if (!preg_match($pattern, $trimmed)) { + throw new ProductCatalogFilterException($invalidKey); + } + $id = (int) $trimmed; + if ($id > 0) { + $ids[] = $id; + } + } + + $ids = array_values(array_unique($ids)); + if ($rejectEmptyAfterTokens && $sawToken && $ids === []) { + throw new ProductCatalogFilterException($invalidKey); + } + if (count($ids) > $max) { + throw new ProductCatalogFilterException($limitKey); + } + + return $ids; + } + + /** + * @return list + */ + private static function normalizeOptionValues(mixed $value): array + { + $out = []; + foreach (self::asList($value) as $part) { + if (is_array($part)) { + throw new ProductCatalogFilterException('ms3_err_catalog_option_value_invalid'); + } + $trimmed = trim((string) $part); + if ($trimmed !== '') { + $out[] = $trimmed; + } + } + + return array_values(array_unique($out)); + } + + /** + * @return list + */ + private static function asList(mixed $value): array + { + return is_array($value) ? $value : explode(',', (string) $value); + } + + /** + * @param array $params + */ + private static function parseOptionalFloat(array $params, string $key, string $errorKey): ?float + { + if (!array_key_exists($key, $params) || $params[$key] === '' || $params[$key] === null) { + return null; + } + + if (!is_numeric($params[$key])) { + throw new ProductCatalogFilterException($errorKey); + } + + return (float) $params[$key]; + } +} diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php new file mode 100644 index 00000000..45f3e92a --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php @@ -0,0 +1,49 @@ + $parentIds From `parents` (before nested expand) + * @param list $vendorIds + * @param array> $options key => values (OR within key) + */ + public function __construct( + public readonly array $parentIds = [], + public readonly bool $nested = false, + public readonly ?float $priceMin = null, + public readonly ?float $priceMax = null, + public readonly bool $inStock = false, + public readonly ?int $stockMin = null, + public readonly array $vendorIds = [], + public readonly bool $flagNew = false, + public readonly bool $flagPopular = false, + public readonly bool $flagFavorite = false, + public readonly array $options = [], + ) { + } + + public function hasParents(): bool + { + return $this->parentIds !== []; + } + + public function hasDataFilters(): bool + { + return $this->priceMin !== null + || $this->priceMax !== null + || $this->inStock + || $this->stockMin !== null + || $this->vendorIds !== [] + || $this->flagNew + || $this->flagPopular + || $this->flagFavorite + || $this->options !== []; + } +} diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php index ed0ddd69..e97cce91 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogService.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -7,6 +7,7 @@ use MiniShop3\Model\msProduct; use MiniShop3\Model\msProductData; use MiniShop3\Services\Catalog\CatalogQuery; +use MiniShop3\Services\Category\CategoryProductScopeService; use MiniShop3\Services\Option\OptionService; use MODX\Revolution\modX; use xPDO\Om\xPDOQuery; @@ -190,27 +191,32 @@ public function getById(int $productId, array $params = []): ?array * Paginated product list. * * Supported filters in $params: - * - parent|category: primary parent resource id (not msCategoryMember) - * - limit, offset | page - * - sort, dir (ASC|DESC) - * - query: pagetitle / article search - * - context: MODX context key (default: current) - * - include_options: 0|1 (default 0 for list) - * - include_content: 0|1 (default 0 for list) + * - parent|category: primary parent resource id (BC; ignored when `parents` set) + * - parents: CSV/array of category IDs (OR; + msCategoryMember via scope) + * - nested: 0|1 expand category tree when using parents + * - price_min / price_max: filter on stored Data.price (not plugin getPrice()) + * - in_stock, stock_min, vendor_id, new, popular, favorite + * - options: JSON object or bracket map (AND between keys, OR within key) + * - limit, offset | page, sort, dir, query, context + * - include_options, include_content * * @param array $params * @return array{items: list>, total: int, limit: int, offset: int} + * + * @throws ProductCatalogFilterException */ public function getList(array $params): array { + $filters = ProductCatalogFilterParser::parse($params); + $limit = self::resolveLimit($params); $offset = self::resolveOffset($params, $limit); $includeOptions = self::toBool($params['include_options'] ?? false); $includeContent = self::toBool($params['include_content'] ?? false); - $total = $this->countList($params); + $total = $this->countList($params, $filters); - $listQuery = $this->buildListQuery($params); + $listQuery = $this->buildListQuery($params, $filters); $this->applyListSelect($listQuery, $includeContent); $this->applySort($listQuery, $params); $listQuery->limit($limit, $offset); @@ -267,11 +273,15 @@ private function resolveContext(array $params): string /** * @param array $params */ - private function countList(array $params): int + private function countList(array $params, ProductCatalogFilterSpec $filters): int { - $countQuery = $this->buildListQuery($params); - // 1:1 join on Data — DISTINCT is unnecessary until many-joins are added. - $countQuery->select('COUNT(msProduct.id)'); + $countQuery = $this->buildListQuery($params, $filters, false); + // Option JOINs can duplicate product rows; 1:1 Data join does not. + $countQuery->select( + $filters->options !== [] + ? 'COUNT(DISTINCT msProduct.id)' + : 'COUNT(msProduct.id)' + ); if (!$countQuery->prepare() || !$countQuery->stmt->execute()) { return 0; } @@ -281,9 +291,13 @@ private function countList(array $params): int /** * @param array $params + * @param bool $dedupeRows GROUP BY for list pages; false for COUNT(DISTINCT) queries */ - private function buildListQuery(array $params): xPDOQuery - { + private function buildListQuery( + array $params, + ProductCatalogFilterSpec $filters, + bool $dedupeRows = true, + ): xPDOQuery { $c = $this->modx->newQuery(msProduct::class); $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); $c->where($this->publicCriteria()); @@ -293,9 +307,11 @@ private function buildListQuery(array $params): xPDOQuery $c->where(['msProduct.context_key' => $context]); } - $parent = (int) ($params['parent'] ?? $params['category'] ?? 0); - if ($parent > 0) { - $c->where(['msProduct.parent' => $parent]); + if (!$filters->hasParents()) { + $parent = (int) ($params['parent'] ?? $params['category'] ?? 0); + if ($parent > 0) { + $c->where(['msProduct.parent' => $parent]); + } } $query = trim((string) ($params['query'] ?? '')); @@ -306,9 +322,19 @@ private function buildListQuery(array $params): xPDOQuery ]); } + $this->filterApplier()->apply($c, $filters, $dedupeRows); + return $c; } + private function filterApplier(): ProductCatalogFilterApplier + { + /** @var CategoryProductScopeService $scope */ + $scope = $this->modx->services->get('ms3_category_product_scope'); + + return new ProductCatalogFilterApplier($this->modx, $scope); + } + /** * Limit selected resource columns; skip content blob on PLP when not requested. * Same pattern as ms3_products snippet. diff --git a/core/components/minishop3/tests/ProductCatalogFilterParserTest.php b/core/components/minishop3/tests/ProductCatalogFilterParserTest.php new file mode 100644 index 00000000..66416752 --- /dev/null +++ b/core/components/minishop3/tests/ProductCatalogFilterParserTest.php @@ -0,0 +1,146 @@ +getLexiconKey() !== $lexiconKey) { + $fail($case . ': expected lexicon ' . $lexiconKey . ', got ' . $e->getLexiconKey()); + } + } +}; + +// Empty → no filters (regress path) +$empty = ProductCatalogFilterParser::parse([]); +$assertSame([], $empty->parentIds, 'empty parents'); +$assertSame(false, $empty->nested, 'empty nested'); +$assertSame(null, $empty->priceMin, 'empty price_min'); +$assertSame(false, $empty->hasParents(), 'empty hasParents'); +$assertSame(false, $empty->hasDataFilters(), 'empty hasDataFilters'); + +// parents CSV + nested +$spec = ProductCatalogFilterParser::parse([ + 'parents' => '12,15,12', + 'nested' => '1', + 'price_min' => '100', + 'price_max' => '5000', + 'in_stock' => '1', + 'stock_min' => '2', + 'vendor_id' => '3,7', + 'new' => '1', + 'popular' => '0', + 'favorite' => 'yes', + 'options' => '{"color":["red","blue"],"size":"M"}', +]); +$assertSame([12, 15], $spec->parentIds, 'parents unique'); +$assertSame(true, $spec->nested, 'nested'); +$assertSame(100.0, $spec->priceMin, 'price_min'); +$assertSame(5000.0, $spec->priceMax, 'price_max'); +$assertSame(true, $spec->inStock, 'in_stock'); +$assertSame(2, $spec->stockMin, 'stock_min'); +$assertSame([3, 7], $spec->vendorIds, 'vendor_id'); +$assertSame(true, $spec->flagNew, 'new'); +$assertSame(false, $spec->flagPopular, 'popular=0 ignored'); +$assertSame(true, $spec->flagFavorite, 'favorite'); +$assertSame(['color' => ['red', 'blue'], 'size' => ['M']], $spec->options, 'options JSON'); + +// bracket-style options array +$bracket = ProductCatalogFilterParser::parse([ + 'options' => [ + 'color' => 'red,blue', + 'size' => ['M', 'L'], + ], +]); +$assertSame(['color' => ['red', 'blue'], 'size' => ['M', 'L']], $bracket->options, 'options bracket'); + +// empty options object ignored +$assertSame([], ProductCatalogFilterParser::parse(['options' => '{}'])->options, 'empty options'); + +// Rejects +$assertThrows('ms3_err_catalog_price_range', static fn () => ProductCatalogFilterParser::parse([ + 'price_min' => 100, + 'price_max' => 50, +]), 'price range'); + +$assertThrows('ms3_err_catalog_price_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'price_min' => 'abc', +]), 'price invalid'); + +$assertThrows('ms3_err_catalog_vendor_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'vendor_id' => '3,x', +]), 'vendor invalid'); + +$assertThrows('ms3_err_catalog_parents_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'parents' => '12,foo', +]), 'parents invalid'); + +$assertThrows('ms3_err_catalog_options_json', static fn () => ProductCatalogFilterParser::parse([ + 'options' => 'not-json', +]), 'options json'); + +$assertThrows('ms3_err_catalog_option_key_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'options' => ['bad-key!' => ['x']], +]), 'option key'); + +$assertThrows('ms3_err_catalog_options_limit', static fn () => ProductCatalogFilterParser::parse([ + 'options' => array_fill_keys( + array_map(static fn (int $i): string => 'k' . $i, range(1, ProductCatalogFilterParser::MAX_OPTION_KEYS + 1)), + ['v'] + ), +]), 'option keys limit'); + +$assertThrows('ms3_err_catalog_parents_limit', static fn () => ProductCatalogFilterParser::parse([ + 'parents' => implode(',', range(1, ProductCatalogFilterParser::MAX_PARENT_IDS + 1)), +]), 'parents limit'); + +$assertThrows('ms3_err_catalog_parents_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'parents' => '-12,-5', +]), 'parents only negatives'); + +$assertThrows('ms3_err_catalog_stock_invalid', static fn () => ProductCatalogFilterParser::parse([ + 'stock_min' => -1, +]), 'stock_min negative'); + +// Scope where helper (members OR parent) — regress semantics used by applier +$where = \MiniShop3\Services\Category\CategoryProductScopeService::buildProductCategoryScopeWhere([12, 15], [99]); +$assertSame([12, 15], $where['msProduct.parent:IN'], 'scope parents'); +$assertSame([99], $where['OR:msProduct.id:IN'], 'scope members'); + +$whereOnly = \MiniShop3\Services\Category\CategoryProductScopeService::buildProductCategoryScopeWhere([12], []); +$assertSame(['msProduct.parent:IN' => [12]], $whereOnly, 'scope without members'); + +// Empty parents / vendor params stay no-op (BC) +$assertSame([], ProductCatalogFilterParser::parse(['parents' => ''])->parentIds, 'empty parents string'); +$assertSame([], ProductCatalogFilterParser::parse(['parents' => '0'])->parentIds, 'parents=0'); + +// Existing catalog helpers still green +$assertSame(20, ProductCatalogService::resolveLimit([]), 'limit regress'); + +fwrite(STDOUT, "OK ProductCatalogFilterParserTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php b/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php new file mode 100644 index 00000000..32a10297 --- /dev/null +++ b/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php @@ -0,0 +1,77 @@ +