From 15fa05647266444f31f50e790d7542e65dc9978d Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 28 Jul 2026 11:54:15 +0600 Subject: [PATCH 1/2] feat(api): localize CategoryProductsController API messages Replace hardcoded English error/success strings with minishop3:default lexicon keys so manager toasts follow MODX cultureKey (ru/en). --- .../minishop3/lexicon/en/default.inc.php | 18 +++++ .../minishop3/lexicon/ru/default.inc.php | 18 +++++ .../Manager/CategoryProductsController.php | 68 +++++++++++++------ .../CategoryProductActionPermissions.php | 6 +- .../CategoryProductActionPermissionsTest.php | 4 +- .../tests/CategoryProductsLexiconKeysTest.php | 55 +++++++++++++++ 6 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 core/components/minishop3/tests/CategoryProductsLexiconKeysTest.php diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 100bf103..4ccea482 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -200,6 +200,24 @@ $_lang['ms3_err_options_is_not_msproduct'] = '[msOptions] Resource with id = [[+id]] is not a product.'; $_lang['ms3_err_processor_combo_required'] = 'This processor requires combo: true.'; +$_lang['ms3_err_category_id_required'] = 'Category ID is required'; +$_lang['ms3_err_category_nf'] = 'Category not found'; +$_lang['ms3_err_category_products_list_service'] = 'Category products list service is not available'; +$_lang['ms3_err_items_required'] = 'Items array is required'; +$_lang['ms3_err_method_required'] = 'Method is required'; +$_lang['ms3_err_unknown_method'] = 'Unknown method'; +$_lang['ms3_err_access_denied_permission'] = 'Access denied. Required permission: [[+permission]]'; +$_lang['ms3_err_product_ids_required'] = 'Product IDs array is required'; +$_lang['ms3_err_product_ids_invalid'] = 'No valid product IDs provided'; +$_lang['ms3_err_category_products_no_updates'] = 'No products were updated'; +$_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_category_products_reordered'] = 'Products reordered successfully'; +$_lang['ms3_category_product_published'] = 'Product published'; +$_lang['ms3_category_product_unpublished'] = 'Product unpublished'; +$_lang['ms3_category_products_updated'] = '[[+count]] products updated'; + $_lang['ms3_email_subject_new_user'] = 'You placed order #[[+num]] on site [[++site_name]]'; $_lang['ms3_email_subject_new_manager'] = 'You have new order #[[+num]]'; $_lang['ms3_email_subject_paid_user'] = 'You paid for order #[[+num]]'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index a2956ba5..00b47a7e 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -200,6 +200,24 @@ $_lang['ms3_err_options_is_not_msproduct'] = '[msOptions] Ресурс с id = [[+id]] не является товаром.'; $_lang['ms3_err_processor_combo_required'] = 'Этот процессор требует combo: true.'; +$_lang['ms3_err_category_id_required'] = 'Не указан ID категории'; +$_lang['ms3_err_category_nf'] = 'Категория не найдена'; +$_lang['ms3_err_category_products_list_service'] = 'Сервис списка товаров категории недоступен'; +$_lang['ms3_err_items_required'] = 'Не передан массив элементов'; +$_lang['ms3_err_method_required'] = 'Не указан метод'; +$_lang['ms3_err_unknown_method'] = 'Неизвестный метод'; +$_lang['ms3_err_access_denied_permission'] = 'Доступ запрещён. Требуется право: [[+permission]]'; +$_lang['ms3_err_product_ids_required'] = 'Не передан массив ID товаров'; +$_lang['ms3_err_product_ids_invalid'] = 'Не указаны корректные ID товаров'; +$_lang['ms3_err_category_products_no_updates'] = 'Ни один товар не был обновлён'; +$_lang['ms3_err_product_id_required'] = 'Не указан ID товара'; +$_lang['ms3_err_product_nf'] = 'Товар не найден'; +$_lang['ms3_err_product_update_failed'] = 'Не удалось обновить товар'; +$_lang['ms3_category_products_reordered'] = 'Порядок товаров успешно изменён'; +$_lang['ms3_category_product_published'] = 'Товар опубликован'; +$_lang['ms3_category_product_unpublished'] = 'Товар снят с публикации'; +$_lang['ms3_category_products_updated'] = 'Обновлено товаров: [[+count]]'; + $_lang['ms3_email_subject_new_user'] = 'Вы сделали заказ #[[+num]] на сайте [[++site_name]]'; $_lang['ms3_email_subject_new_manager'] = 'У вас новый заказ #[[+num]]'; $_lang['ms3_email_subject_paid_user'] = 'Вы оплатили заказ #[[+num]]'; diff --git a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php index 9eb91679..1645648c 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php @@ -26,6 +26,27 @@ class CategoryProductsController public function __construct(modX $modx) { $this->modx = $modx; + $this->modx->lexicon->load('minishop3:default'); + } + + /** + * Human-readable lexicon entry, or original key when missing/empty translation. + * + * @param array $params + */ + protected function lexiconMessageOrKey(string $key, array $params = []): string + { + $text = $this->modx->lexicon($key, $params); + + return ($text !== $key && $text !== '') ? $text : $key; + } + + /** + * @param array $params + */ + protected function errorResponse(string $messageKey, int $status, array $params = []): array + { + return Response::error($this->lexiconMessageOrKey($messageKey, $params), $status)->getData(); } /** @@ -40,12 +61,12 @@ public function getList(array $params = []): array $categoryId = (int) ($params['id'] ?? 0); if (!$categoryId) { - return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_category_id_required', HttpStatus::BAD_REQUEST); } $category = $this->modx->getObject(msCategory::class, $categoryId); if (!$category) { - return Response::error('Category not found', HttpStatus::NOT_FOUND)->getData(); + return $this->errorResponse('ms3_err_category_nf', HttpStatus::NOT_FOUND); } $start = (int) ($params['start'] ?? 0); @@ -64,7 +85,7 @@ public function getList(array $params = []): array /** @var CategoryProductsListService|null $listService */ $listService = $this->modx->services->get('ms3_category_products_list'); if (!$listService) { - return Response::error('Category products list service is not available', 500)->getData(); + return $this->errorResponse('ms3_err_category_products_list_service', HttpStatus::INTERNAL_SERVER_ERROR); } $page = $listService->getPage( @@ -119,11 +140,11 @@ public function sort(array $params = []): array $nested = $this->isNested($params); if (!$categoryId) { - return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_category_id_required', HttpStatus::BAD_REQUEST); } if (empty($items) || !is_array($items)) { - return Response::error('Items array is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_items_required', HttpStatus::BAD_REQUEST); } $updated = 0; @@ -150,7 +171,7 @@ public function sort(array $params = []): array return Response::success([ 'updated' => $updated, - ], 'Products reordered successfully')->getData(); + ], $this->lexiconMessageOrKey('ms3_category_products_reordered'))->getData(); } /** @@ -172,7 +193,7 @@ public function multiple(array $params = []): array } if (empty($method)) { - return Response::error('Method is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_method_required', HttpStatus::BAD_REQUEST); } $access = CategoryProductActionPermissions::evaluate( @@ -194,11 +215,15 @@ public function multiple(array $params = []): array ); } - return Response::error($access['message'], $access['status'])->getData(); + return $this->errorResponse( + $access['message'], + $access['status'], + ['permission' => $access['permission'] ?? ''] + ); } if (empty($ids) || !is_array($ids)) { - return Response::error('Product IDs array is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_product_ids_required', HttpStatus::BAD_REQUEST); } // Sanitize IDs @@ -207,7 +232,7 @@ public function multiple(array $params = []): array }); if (empty($ids)) { - return Response::error('No valid product IDs provided', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_product_ids_invalid', HttpStatus::BAD_REQUEST); } $success = 0; @@ -242,13 +267,13 @@ public function multiple(array $params = []): array } if ($success === 0) { - return Response::error('No products were updated', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); + return $this->errorResponse('ms3_err_category_products_no_updates', HttpStatus::INTERNAL_SERVER_ERROR); } return Response::success([ 'success' => $success, 'failed' => $failed, - ], "{$success} products updated")->getData(); + ], $this->lexiconMessageOrKey('ms3_category_products_updated', ['count' => $success]))->getData(); } /** @@ -284,7 +309,7 @@ public function publish(array $params = []): array } if (!$productId) { - return Response::error('Product ID is required', HttpStatus::BAD_REQUEST)->getData(); + return $this->errorResponse('ms3_err_product_id_required', HttpStatus::BAD_REQUEST); } if ($denied = $this->denyWithoutPermission('msproduct_publish')) { @@ -295,7 +320,7 @@ public function publish(array $params = []): array $product = $scope->findInCategory($categoryId, $productId, $nested); if (!$product) { - return Response::error('Product not found', HttpStatus::NOT_FOUND)->getData(); + return $this->errorResponse('ms3_err_product_nf', HttpStatus::NOT_FOUND); } // If published param not provided, toggle current state @@ -304,13 +329,15 @@ public function publish(array $params = []): array } if (!$this->applyPublish($product, (bool) $published)) { - return Response::error('Failed to update product', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); + return $this->errorResponse('ms3_err_product_update_failed', HttpStatus::INTERNAL_SERVER_ERROR); } return Response::success([ 'id' => $productId, 'published' => $published, - ], $published ? 'Product published' : 'Product unpublished')->getData(); + ], $this->lexiconMessageOrKey( + $published ? 'ms3_category_product_published' : 'ms3_category_product_unpublished' + ))->getData(); } private function isNested(array $params): bool @@ -343,10 +370,11 @@ private function denyWithoutPermission(string $permission): ?array . ' (user id ' . (int)($this->modx->user->get('id') ?? 0) . ')' ); - return Response::error( - "Access denied. Required permission: {$permission}", - HttpStatus::FORBIDDEN - )->getData(); + return $this->errorResponse( + 'ms3_err_access_denied_permission', + HttpStatus::FORBIDDEN, + ['permission' => $permission] + ); } private function applyPublish(msProduct $product, bool $published): bool diff --git a/core/components/minishop3/src/Services/Category/CategoryProductActionPermissions.php b/core/components/minishop3/src/Services/Category/CategoryProductActionPermissions.php index 265e451b..7cb0124c 100644 --- a/core/components/minishop3/src/Services/Category/CategoryProductActionPermissions.php +++ b/core/components/minishop3/src/Services/Category/CategoryProductActionPermissions.php @@ -38,6 +38,8 @@ public static function forMethod(string $method): ?string * permission: ?string, * reason: 'ok'|'unknown_method'|'forbidden' * } + * + * When denied, `message` is a lexicon key (resolved in CategoryProductsController). */ public static function evaluate(string $method, callable $hasPermission): array { @@ -46,7 +48,7 @@ public static function evaluate(string $method, callable $hasPermission): array return [ 'allowed' => false, 'status' => HttpStatus::BAD_REQUEST, - 'message' => 'Unknown method', + 'message' => 'ms3_err_unknown_method', 'permission' => null, 'reason' => 'unknown_method', ]; @@ -56,7 +58,7 @@ public static function evaluate(string $method, callable $hasPermission): array return [ 'allowed' => false, 'status' => HttpStatus::FORBIDDEN, - 'message' => "Access denied. Required permission: {$permission}", + 'message' => 'ms3_err_access_denied_permission', 'permission' => $permission, 'reason' => 'forbidden', ]; diff --git a/core/components/minishop3/tests/CategoryProductActionPermissionsTest.php b/core/components/minishop3/tests/CategoryProductActionPermissionsTest.php index 4aee8695..3e5def20 100644 --- a/core/components/minishop3/tests/CategoryProductActionPermissionsTest.php +++ b/core/components/minishop3/tests/CategoryProductActionPermissionsTest.php @@ -63,7 +63,7 @@ $assertSame(false, $unknown['allowed'], 'unknown allowed'); $assertSame(HttpStatus::BAD_REQUEST, $unknown['status'], 'unknown status'); $assertSame('unknown_method', $unknown['reason'], 'unknown reason'); -$assertSame('Unknown method', $unknown['message'], 'unknown message'); +$assertSame('ms3_err_unknown_method', $unknown['message'], 'unknown message'); // Missing permission → 403 before any product mutation $forbidden = CategoryProductActionPermissions::evaluate('delete', $denyAll); @@ -72,7 +72,7 @@ $assertSame('forbidden', $forbidden['reason'], 'forbidden reason'); $assertSame('msproduct_delete', $forbidden['permission'], 'forbidden permission'); $assertSame( - 'Access denied. Required permission: msproduct_delete', + 'ms3_err_access_denied_permission', $forbidden['message'], 'forbidden message' ); diff --git a/core/components/minishop3/tests/CategoryProductsLexiconKeysTest.php b/core/components/minishop3/tests/CategoryProductsLexiconKeysTest.php new file mode 100644 index 00000000..0109b5c8 --- /dev/null +++ b/core/components/minishop3/tests/CategoryProductsLexiconKeysTest.php @@ -0,0 +1,55 @@ + Date: Wed, 29 Jul 2026 17:52:53 +0600 Subject: [PATCH 2/2] fix(test): stub modX lexicon for CategoryProductsController smoke tests Constructor now calls lexicon->load(); ModxStub lacked the property, which broke CategoryProductsControllerScopeTest. --- .../minishop3/tests/stubs/ModxStub.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/components/minishop3/tests/stubs/ModxStub.php b/core/components/minishop3/tests/stubs/ModxStub.php index 829ff072..551a1bce 100644 --- a/core/components/minishop3/tests/stubs/ModxStub.php +++ b/core/components/minishop3/tests/stubs/ModxStub.php @@ -19,6 +19,9 @@ class modX /** @var object|null */ public $context; + /** @var object */ + public object $lexicon; + /** @var array */ private array $permissions = []; @@ -42,6 +45,20 @@ public function getUserToken(string $contextKey): string return 'test-modauth-token'; } }; + + $this->lexicon = new class { + public function load(string ...$topics): void + { + } + }; + } + + /** + * @param array $params + */ + public function lexicon(string $key, array $params = []): string + { + return $key; } /**