Skip to content
Open
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
18 changes: 18 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]]';
Expand Down
18 changes: 18 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]]';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, scalar|null> $params
*/
protected function lexiconMessageOrKey(string $key, array $params = []): string
{
$text = $this->modx->lexicon($key, $params);

return ($text !== $key && $text !== '') ? $text : $key;
}

/**
* @param array<string, scalar|null> $params
*/
protected function errorResponse(string $messageKey, int $status, array $params = []): array
{
return Response::error($this->lexiconMessageOrKey($messageKey, $params), $status)->getData();
}

/**
Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}

/**
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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')) {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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',
];
Expand All @@ -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',
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/**
* Category products API messages must exist in ru/en default lexicon (#446).
*
* Run: php tests/CategoryProductsLexiconKeysTest.php
*/

declare(strict_types=1);

$fail = static function (string $message): never {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
};

$keys = [
'ms3_err_category_id_required',
'ms3_err_category_nf',
'ms3_err_category_products_list_service',
'ms3_err_items_required',
'ms3_err_method_required',
'ms3_err_unknown_method',
'ms3_err_access_denied_permission',
'ms3_err_product_ids_required',
'ms3_err_product_ids_invalid',
'ms3_err_category_products_no_updates',
'ms3_err_product_id_required',
'ms3_err_product_nf',
'ms3_err_product_update_failed',
'ms3_category_products_reordered',
'ms3_category_product_published',
'ms3_category_product_unpublished',
'ms3_category_products_updated',
];

foreach (['en', 'ru'] as $lang) {
$path = __DIR__ . '/../lexicon/' . $lang . '/default.inc.php';
if (!is_readable($path)) {
$fail("Missing lexicon file: {$path}");
}

$contents = file_get_contents($path);
if ($contents === false) {
$fail("Cannot read {$path}");
}

foreach ($keys as $key) {
if (!str_contains($contents, "'{$key}'")) {
$fail("Missing {$key} in {$lang}/default.inc.php");
}
}
}

fwrite(STDOUT, 'OK CategoryProductsLexiconKeysTest (' . count($keys) . " keys × 2 langs)\n");
exit(0);
17 changes: 17 additions & 0 deletions core/components/minishop3/tests/stubs/ModxStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class modX
/** @var object|null */
public $context;

/** @var object */
public object $lexicon;

/** @var array<string, bool> */
private array $permissions = [];

Expand All @@ -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<string, scalar|null> $params
*/
public function lexicon(string $key, array $params = []): string
{
return $key;
}

/**
Expand Down