From dd4683ba6f7c8783222bb9123ca25aa0029f1aa3 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 16 Aug 2026 15:42:05 +0600 Subject: [PATCH 1/2] feat(web-api): public delivery and payment discovery catalogs Add GET /api/v1/delivery|payment list/get with hard allowlists so headless checkouts can discover active methods without Manager API or leaking gateway properties. --- .../minishop3/config/routes/web.php | 25 +++ .../minishop3/lexicon/en/default.inc.php | 2 + .../minishop3/lexicon/ru/default.inc.php | 2 + .../Api/Web/DeliveryController.php | 70 ++++++ .../Controllers/Api/Web/PaymentController.php | 70 ++++++ .../src/Middleware/TokenMiddleware.php | 4 + .../minishop3/src/ServiceRegistry.php | 8 + .../src/ServiceRegistryFactories.php | 2 + .../src/Services/Catalog/CatalogLexicon.php | 22 ++ .../src/Services/Catalog/CatalogQuery.php | 12 + .../Services/Catalog/CheckoutMemberMap.php | 114 ++++++++++ .../Delivery/DeliveryCatalogService.php | 208 ++++++++++++++++++ .../Payment/PaymentCatalogService.php | 157 +++++++++++++ .../minishop3/tests/CheckoutMemberMapTest.php | 67 ++++++ .../tests/DeliveryCatalogServiceTest.php | 102 +++++++++ .../DeliveryPaymentCatalogRoutesTest.php | 85 +++++++ .../tests/PaymentCatalogServiceTest.php | 79 +++++++ .../tests/TokenMiddlewarePublicRoutesTest.php | 4 + 18 files changed, 1033 insertions(+) create mode 100644 core/components/minishop3/src/Controllers/Api/Web/DeliveryController.php create mode 100644 core/components/minishop3/src/Controllers/Api/Web/PaymentController.php create mode 100644 core/components/minishop3/src/Services/Catalog/CatalogLexicon.php create mode 100644 core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php create mode 100644 core/components/minishop3/src/Services/Delivery/DeliveryCatalogService.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentCatalogService.php create mode 100644 core/components/minishop3/tests/CheckoutMemberMapTest.php create mode 100644 core/components/minishop3/tests/DeliveryCatalogServiceTest.php create mode 100644 core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php create mode 100644 core/components/minishop3/tests/PaymentCatalogServiceTest.php diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index c9ebce46..b2b7177b 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -291,6 +291,31 @@ }); }); + // Public checkout discovery — active deliveries / payments (no cart token) + $router->group('/delivery', function ($router) use ($modx) { + $router->get('/get/{id}', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\DeliveryController($modx); + return $controller->get($params); + }); + + $router->get('/list', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\DeliveryController($modx); + return $controller->getList($params); + }); + }); + + $router->group('/payment', function ($router) use ($modx) { + $router->get('/get/{id}', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\PaymentController($modx); + return $controller->get($params); + }); + + $router->get('/list', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\PaymentController($modx); + return $controller->getList($params); + }); + }); + $router->get('/health', function () use ($modx) { return Response::success([ 'status' => 'ok', diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 086a4b4e..0a93c8e1 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -186,7 +186,9 @@ $_lang['ms3_err_product_not_in_category_scope'] = 'Product is not in the scope of this category.'; $_lang['ms3_err_status_nf'] = 'Status with this identifier not found.'; $_lang['ms3_err_delivery_nf'] = 'Delivery method with this identifier not found.'; +$_lang['ms3_err_delivery_id_required'] = 'Delivery ID is required'; $_lang['ms3_err_payment_nf'] = 'Payment method with this identifier not found.'; +$_lang['ms3_err_payment_id_required'] = 'Payment ID is required'; $_lang['ms3_err_status_final'] = 'Final status is set. It cannot be changed.'; $_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.'; $_lang['ms3_err_status_wrong'] = 'Invalid order status.'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 490de748..a2e91fd9 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -186,7 +186,9 @@ $_lang['ms3_err_product_not_in_category_scope'] = 'Товар не входит в область этой категории.'; $_lang['ms3_err_status_nf'] = 'Статус с таким идентификатором не найден.'; $_lang['ms3_err_delivery_nf'] = 'Способ доставки с таким идентификатором не найден.'; +$_lang['ms3_err_delivery_id_required'] = 'Не указан ID доставки'; $_lang['ms3_err_payment_nf'] = 'Способ оплаты с таким идентификатором не найден.'; +$_lang['ms3_err_payment_id_required'] = 'Не указан ID оплаты'; $_lang['ms3_err_status_final'] = 'Установлен финальный статус. Его нельзя менять.'; $_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.'; $_lang['ms3_err_status_wrong'] = 'Неверный статус заказа.'; diff --git a/core/components/minishop3/src/Controllers/Api/Web/DeliveryController.php b/core/components/minishop3/src/Controllers/Api/Web/DeliveryController.php new file mode 100644 index 00000000..22698bd7 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Web/DeliveryController.php @@ -0,0 +1,70 @@ +modx = $modx; + $this->modx->lexicon->load('minishop3:default'); + } + + /** + * GET /api/v1/delivery/get/{id} + * + * @param array $params + */ + public function get(array $params = []): Response + { + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return Response::error( + $this->modx->lexicon('ms3_err_delivery_id_required'), + HttpStatus::BAD_REQUEST + ); + } + + $item = $this->catalog()->getById($id, $params); + if ($item === null) { + return Response::error( + $this->modx->lexicon('ms3_err_delivery_nf'), + HttpStatus::NOT_FOUND + ); + } + + return Response::success($item); + } + + /** + * GET /api/v1/delivery/list + * + * Query: include_payments (default true), include_required_fields (default false) + * + * @param array $params + */ + public function getList(array $params = []): Response + { + return Response::success($this->catalog()->getList($params)); + } + + private function catalog(): DeliveryCatalogService + { + /** @var DeliveryCatalogService $service */ + $service = $this->modx->services->get('ms3_delivery_catalog'); + + return $service; + } +} diff --git a/core/components/minishop3/src/Controllers/Api/Web/PaymentController.php b/core/components/minishop3/src/Controllers/Api/Web/PaymentController.php new file mode 100644 index 00000000..e4519f18 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Web/PaymentController.php @@ -0,0 +1,70 @@ +modx = $modx; + $this->modx->lexicon->load('minishop3:default'); + } + + /** + * GET /api/v1/payment/get/{id} + * + * @param array $params + */ + public function get(array $params = []): Response + { + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return Response::error( + $this->modx->lexicon('ms3_err_payment_id_required'), + HttpStatus::BAD_REQUEST + ); + } + + $item = $this->catalog()->getById($id, $params); + if ($item === null) { + return Response::error( + $this->modx->lexicon('ms3_err_payment_nf'), + HttpStatus::NOT_FOUND + ); + } + + return Response::success($item); + } + + /** + * GET /api/v1/payment/list + * + * Query: delivery_id (optional), include_delivery_ids (default false) + * + * @param array $params + */ + public function getList(array $params = []): Response + { + return Response::success($this->catalog()->getList($params)); + } + + private function catalog(): PaymentCatalogService + { + /** @var PaymentCatalogService $service */ + $service = $this->modx->services->get('ms3_payment_catalog'); + + return $service; + } +} diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 17fb82da..41ee0687 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -35,6 +35,10 @@ class TokenMiddleware implements MiddlewareInterface '/api/v1/category/get/', '/api/v1/category/list', '/api/v1/category/tree', + '/api/v1/delivery/get/', + '/api/v1/delivery/list', + '/api/v1/payment/get/', + '/api/v1/payment/list', '/api/v1/customer/token/get', '/api/v1/customer/logout', '/api/v1/health', diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 14457cdf..296706fd 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -162,6 +162,14 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Category\CategoryCatalogService::class, 'interface' => null, ], + 'ms3_delivery_catalog' => [ + 'class' => \MiniShop3\Services\Delivery\DeliveryCatalogService::class, + 'interface' => null, + ], + 'ms3_payment_catalog' => [ + 'class' => \MiniShop3\Services\Payment\PaymentCatalogService::class, + 'interface' => null, + ], 'ms3_repeater_field' => [ 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index d5eea92c..03585364 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -40,6 +40,8 @@ public static function map(): array 'ms3_product_link_service' => $modxOnly(), 'ms3_product_catalog' => $modxOnly(), 'ms3_category_catalog' => $modxOnly(), + 'ms3_delivery_catalog' => $modxOnly(), + 'ms3_payment_catalog' => $modxOnly(), 'ms3_repeater_field' => $modxOnly(), 'ms3_extra_fields' => $modxOnly(), 'ms3_key_value_field' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Catalog/CatalogLexicon.php b/core/components/minishop3/src/Services/Catalog/CatalogLexicon.php new file mode 100644 index 00000000..a3ca3051 --- /dev/null +++ b/core/components/minishop3/src/Services/Catalog/CatalogLexicon.php @@ -0,0 +1,22 @@ +lexicon($name); + } +} diff --git a/core/components/minishop3/src/Services/Catalog/CatalogQuery.php b/core/components/minishop3/src/Services/Catalog/CatalogQuery.php index 5bda65aa..8d6be7f1 100644 --- a/core/components/minishop3/src/Services/Catalog/CatalogQuery.php +++ b/core/components/minishop3/src/Services/Catalog/CatalogQuery.php @@ -100,4 +100,16 @@ public static function toBool(mixed $value): bool return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); } + + /** + * @param array $params + */ + public static function resolveBool(array $params, string $key, bool $default): bool + { + if (!array_key_exists($key, $params)) { + return $default; + } + + return self::toBool($params[$key]); + } } diff --git a/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php b/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php new file mode 100644 index 00000000..034af03b --- /dev/null +++ b/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php @@ -0,0 +1,114 @@ +modx = $modx; + } + + public function isActiveDelivery(int $deliveryId): bool + { + if ($deliveryId <= 0) { + return false; + } + + return (bool) $this->modx->getCount(msDelivery::class, [ + 'id' => $deliveryId, + 'active' => 1, + ]); + } + + /** + * @return array> delivery_id => payment ids (position ASC, id ASC) + */ + public function paymentIdsByDelivery(): array + { + $c = $this->modx->newQuery(msPayment::class); + $c->setClassAlias('msPayment'); + $c->select([ + 'msPayment.id AS payment_id', + 'Member.delivery_id AS delivery_id', + ]); + $c->innerJoin( + msDeliveryMember::class, + 'Member', + 'Member.payment_id = msPayment.id' + ); + $c->innerJoin( + msDelivery::class, + 'Delivery', + 'Delivery.id = Member.delivery_id AND Delivery.active = 1' + ); + $c->where(['msPayment.active' => 1]); + $c->sortby('msPayment.position', 'ASC'); + $c->sortby('msPayment.id', 'ASC'); + + return $this->fetchGroupedIds($c, 'delivery_id', 'payment_id'); + } + + /** + * @return array> payment_id => delivery ids (position ASC, id ASC) + */ + public function deliveryIdsByPayment(): array + { + $c = $this->modx->newQuery(msDelivery::class); + $c->setClassAlias('msDelivery'); + $c->select([ + 'msDelivery.id AS delivery_id', + 'Member.payment_id AS payment_id', + ]); + $c->innerJoin( + msDeliveryMember::class, + 'Member', + 'Member.delivery_id = msDelivery.id' + ); + $c->innerJoin( + msPayment::class, + 'Payment', + 'Payment.id = Member.payment_id AND Payment.active = 1' + ); + $c->where(['msDelivery.active' => 1]); + $c->sortby('msDelivery.position', 'ASC'); + $c->sortby('msDelivery.id', 'ASC'); + + return $this->fetchGroupedIds($c, 'payment_id', 'delivery_id'); + } + + /** + * @return array> + */ + private function fetchGroupedIds(xPDOQuery $query, string $groupKey, string $idKey): array + { + if (!$query->prepare() || !$query->stmt->execute()) { + return []; + } + + $map = []; + while ($row = $query->stmt->fetch(\PDO::FETCH_ASSOC)) { + $groupId = (int) ($row[$groupKey] ?? 0); + $memberId = (int) ($row[$idKey] ?? 0); + if ($groupId <= 0 || $memberId <= 0) { + continue; + } + $map[$groupId][] = $memberId; + } + + return $map; + } +} diff --git a/core/components/minishop3/src/Services/Delivery/DeliveryCatalogService.php b/core/components/minishop3/src/Services/Delivery/DeliveryCatalogService.php new file mode 100644 index 00000000..82860dd1 --- /dev/null +++ b/core/components/minishop3/src/Services/Delivery/DeliveryCatalogService.php @@ -0,0 +1,208 @@ + + */ + public const PUBLIC_FIELDS = [ + 'id', + 'name', + 'description', + 'price', + 'weight_price', + 'distance_price', + 'logo', + 'position', + 'active', + 'free_delivery_amount', + ]; + + protected modX $modx; + + public function __construct(modX $modx) + { + $this->modx = $modx; + } + + /** + * @param array $params + * @return array{items: list>, total: int} + */ + public function getList(array $params = []): array + { + $includePayments = CatalogQuery::resolveBool($params, 'include_payments', true); + $includeRequired = CatalogQuery::resolveBool($params, 'include_required_fields', false); + $paymentIdsByDelivery = $includePayments + ? (new CheckoutMemberMap($this->modx))->paymentIdsByDelivery() + : []; + + $c = $this->modx->newQuery(msDelivery::class); + $c->where(['active' => 1]); + $c->sortby('position', 'ASC'); + $c->sortby('id', 'ASC'); + + $items = []; + foreach ($this->modx->getIterator(msDelivery::class, $c) as $delivery) { + $items[] = $this->formatItem( + $delivery, + $includePayments, + $includeRequired, + $paymentIdsByDelivery + ); + } + + return [ + 'items' => $items, + 'total' => count($items), + ]; + } + + /** + * @param array $params + * @return array|null + */ + public function getById(int $id, array $params = []): ?array + { + if ($id <= 0) { + return null; + } + + $delivery = $this->modx->getObject(msDelivery::class, [ + 'id' => $id, + 'active' => 1, + ]); + if (!$delivery instanceof msDelivery) { + return null; + } + + $includePayments = CatalogQuery::resolveBool($params, 'include_payments', true); + $paymentIdsByDelivery = $includePayments + ? (new CheckoutMemberMap($this->modx))->paymentIdsByDelivery() + : []; + + return $this->formatItem( + $delivery, + $includePayments, + CatalogQuery::resolveBool($params, 'include_required_fields', false), + $paymentIdsByDelivery + ); + } + + /** + * Project a row onto the public allowlist (no MODX required). + * + * @param array $data + * @return array + */ + public static function projectPublicFields(array $data): array + { + $out = []; + foreach (self::PUBLIC_FIELDS as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $raw = $data[$field]; + $out[$field] = match ($field) { + 'id', 'position' => (int) $raw, + 'active' => (bool) $raw, + 'weight_price', 'distance_price', 'free_delivery_amount' => self::normalizeNumber($raw), + default => $raw, + }; + } + + return $out; + } + + /** + * Field names that include a Rakit `required` rule (names only, no rule dump). + * + * @return list + */ + public static function extractRequiredFieldNames(mixed $validationRules): array + { + if (is_string($validationRules)) { + if ($validationRules === '') { + return []; + } + $decoded = json_decode($validationRules, true); + $validationRules = is_array($decoded) ? $decoded : []; + } + + if (!is_array($validationRules)) { + return []; + } + + $names = []; + foreach ($validationRules as $field => $rules) { + if (!is_string($field) || $field === '' || !is_string($rules)) { + continue; + } + $parts = array_map('trim', explode('|', $rules)); + if (in_array('required', $parts, true)) { + $names[] = $field; + } + } + + return $names; + } + + /** + * @param array> $paymentIdsByDelivery + * @return array + */ + private function formatItem( + msDelivery $delivery, + bool $includePayments, + bool $includeRequired, + array $paymentIdsByDelivery + ): array { + $raw = []; + foreach (self::PUBLIC_FIELDS as $field) { + $raw[$field] = $delivery->get($field); + } + + $item = self::projectPublicFields($raw); + $item['name'] = CatalogLexicon::translateMs3Name($this->modx, (string) ($item['name'] ?? '')); + + if ($includePayments) { + $deliveryId = (int) $delivery->get('id'); + $item['payment_ids'] = $paymentIdsByDelivery[$deliveryId] ?? []; + } + + if ($includeRequired) { + $item['required_fields'] = self::extractRequiredFieldNames($delivery->get('validation_rules')); + } + + return $item; + } + + private static function normalizeNumber(mixed $raw): int|float|string + { + if (is_int($raw) || is_float($raw)) { + return $raw; + } + if (is_numeric($raw)) { + return str_contains((string) $raw, '.') ? (float) $raw : (int) $raw; + } + + return $raw ?? 0; + } +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentCatalogService.php b/core/components/minishop3/src/Services/Payment/PaymentCatalogService.php new file mode 100644 index 00000000..8e6a82de --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentCatalogService.php @@ -0,0 +1,157 @@ + + */ + public const PUBLIC_FIELDS = [ + 'id', + 'name', + 'description', + 'price', + 'logo', + 'position', + 'active', + ]; + + protected modX $modx; + + public function __construct(modX $modx) + { + $this->modx = $modx; + } + + /** + * @param array $params + * @return array{items: list>, total: int} + */ + public function getList(array $params = []): array + { + $deliveryId = (int) ($params['delivery_id'] ?? 0); + $includeDeliveryIds = CatalogQuery::resolveBool($params, 'include_delivery_ids', false); + $members = new CheckoutMemberMap($this->modx); + + if ($deliveryId > 0 && !$members->isActiveDelivery($deliveryId)) { + return [ + 'items' => [], + 'total' => 0, + ]; + } + + $c = $this->modx->newQuery(msPayment::class); + $c->where(['msPayment.active' => 1]); + + if ($deliveryId > 0) { + $c->innerJoin( + msDeliveryMember::class, + 'Member', + 'Member.payment_id = msPayment.id AND Member.delivery_id = ' . $deliveryId + ); + } + + $c->sortby('msPayment.position', 'ASC'); + $c->sortby('msPayment.id', 'ASC'); + + $deliveryIdsByPayment = $includeDeliveryIds ? $members->deliveryIdsByPayment() : []; + + $items = []; + foreach ($this->modx->getIterator(msPayment::class, $c) as $payment) { + $items[] = $this->formatItem($payment, $includeDeliveryIds, $deliveryIdsByPayment); + } + + return [ + 'items' => $items, + 'total' => count($items), + ]; + } + + /** + * @param array $params + * @return array|null + */ + public function getById(int $id, array $params = []): ?array + { + if ($id <= 0) { + return null; + } + + $payment = $this->modx->getObject(msPayment::class, [ + 'id' => $id, + 'active' => 1, + ]); + if (!$payment instanceof msPayment) { + return null; + } + + $includeDeliveryIds = CatalogQuery::resolveBool($params, 'include_delivery_ids', false); + $deliveryIdsByPayment = $includeDeliveryIds + ? (new CheckoutMemberMap($this->modx))->deliveryIdsByPayment() + : []; + + return $this->formatItem($payment, $includeDeliveryIds, $deliveryIdsByPayment); + } + + /** + * @param array $data + * @return array + */ + public static function projectPublicFields(array $data): array + { + $out = []; + foreach (self::PUBLIC_FIELDS as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $raw = $data[$field]; + $out[$field] = match ($field) { + 'id', 'position' => (int) $raw, + 'active' => (bool) $raw, + default => $raw, + }; + } + + return $out; + } + + /** + * @param array> $deliveryIdsByPayment + * @return array + */ + private function formatItem( + msPayment $payment, + bool $includeDeliveryIds, + array $deliveryIdsByPayment + ): array { + $raw = []; + foreach (self::PUBLIC_FIELDS as $field) { + $raw[$field] = $payment->get($field); + } + + $item = self::projectPublicFields($raw); + $item['name'] = CatalogLexicon::translateMs3Name($this->modx, (string) ($item['name'] ?? '')); + + if ($includeDeliveryIds) { + $paymentId = (int) $payment->get('id'); + $item['delivery_ids'] = $deliveryIdsByPayment[$paymentId] ?? []; + } + + return $item; + } +} diff --git a/core/components/minishop3/tests/CheckoutMemberMapTest.php b/core/components/minishop3/tests/CheckoutMemberMapTest.php new file mode 100644 index 00000000..51dc5c79 --- /dev/null +++ b/core/components/minishop3/tests/CheckoutMemberMapTest.php @@ -0,0 +1,67 @@ +getNumberOfParameters() !== 2) { + $fail('translateMs3Name must accept modX + name'); +} + +$refMap = new ReflectionClass(CheckoutMemberMap::class); +if (!$refMap->hasMethod('isActiveDelivery')) { + $fail('CheckoutMemberMap reflection missing isActiveDelivery'); +} + +fwrite(STDOUT, "OK CheckoutMemberMapTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/DeliveryCatalogServiceTest.php b/core/components/minishop3/tests/DeliveryCatalogServiceTest.php new file mode 100644 index 00000000..d0767012 --- /dev/null +++ b/core/components/minishop3/tests/DeliveryCatalogServiceTest.php @@ -0,0 +1,102 @@ + 7, + 'name' => 'Courier', + 'description' => 'City', + 'price' => '300', + 'weight_price' => '10', + 'distance_price' => 0, + 'logo' => '/a.png', + 'position' => 2, + 'active' => 1, + 'free_delivery_amount' => 5000, + 'properties' => ['api_key' => 'secret'], + 'class' => 'MiniShop3\\Controllers\\Delivery\\Delivery', + 'validation_rules' => '{"phone":"required"}', +]); + +foreach ($forbidden as $field) { + if (array_key_exists($field, $projected)) { + $fail("projected payload must not contain \"{$field}\""); + } +} + +if ($projected['id'] !== 7 || $projected['active'] !== true || $projected['position'] !== 2) { + $fail('projected field casting failed'); +} + +if (DeliveryCatalogService::extractRequiredFieldNames('{"phone":"required|numeric","city":"nullable"}') !== ['phone']) { + $fail('extractRequiredFieldNames must return required field names only'); +} + +if (DeliveryCatalogService::extractRequiredFieldNames(['email' => 'required|email']) !== ['email']) { + $fail('extractRequiredFieldNames must accept array rules'); +} + +if (DeliveryCatalogService::extractRequiredFieldNames(null) !== []) { + $fail('extractRequiredFieldNames null → []'); +} + +if (CatalogQuery::resolveBool([], 'include_payments', true) !== true) { + $fail('resolveBool default true'); +} +if (CatalogQuery::resolveBool(['include_payments' => '0'], 'include_payments', true) !== false) { + $fail('resolveBool 0 → false'); +} + +$serviceSrc = file_get_contents(__DIR__ . '/../src/Services/Delivery/DeliveryCatalogService.php'); +if ($serviceSrc === false) { + $fail('cannot read DeliveryCatalogService.php'); +} +if (str_contains($serviceSrc, 'toArray(')) { + $fail('DeliveryCatalogService must not call toArray()'); +} +if (!str_contains($serviceSrc, "'active' => 1")) { + $fail('list/get must scope active=1'); +} + +fwrite(STDOUT, "OK DeliveryCatalogServiceTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php new file mode 100644 index 00000000..23e0e9e5 --- /dev/null +++ b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php @@ -0,0 +1,85 @@ + 2, + 'name' => 'Card', + 'description' => 'Online', + 'price' => '0%', + 'logo' => '/card.png', + 'position' => 1, + 'active' => 1, + 'properties' => ['merchant' => 'secret'], + 'class' => 'MiniShop3\\Controllers\\Payment\\Payment', + 'payment_link' => 'https://gateway.example/pay', +]); + +foreach ($forbidden as $field) { + if (array_key_exists($field, $projected)) { + $fail("projected payload must not contain \"{$field}\""); + } +} + +if ($projected['id'] !== 2 || $projected['active'] !== true) { + $fail('projected field casting failed'); +} + +$serviceSrc = file_get_contents(__DIR__ . '/../src/Services/Payment/PaymentCatalogService.php'); +if ($serviceSrc === false) { + $fail('cannot read PaymentCatalogService.php'); +} +if (str_contains($serviceSrc, 'toArray(')) { + $fail('PaymentCatalogService must not call toArray()'); +} +if (!str_contains($serviceSrc, 'delivery_id')) { + $fail('payment list must support delivery_id filter'); +} +if (!str_contains($serviceSrc, 'msDeliveryMember')) { + $fail('delivery_id filter must use msDeliveryMember'); +} +if (!str_contains($serviceSrc, 'isActiveDelivery')) { + $fail('delivery_id filter must require active delivery'); +} +if (str_contains($serviceSrc, 'send(') || str_contains($serviceSrc, 'getPaymentLink')) { + $fail('payment catalog must not generate live payment links'); +} + +fwrite(STDOUT, "OK PaymentCatalogServiceTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php index 0430fed8..8bf307b4 100644 --- a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php +++ b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php @@ -42,6 +42,10 @@ '/api/v1/category/get/', '/api/v1/category/list', '/api/v1/category/tree', + '/api/v1/delivery/get/', + '/api/v1/delivery/list', + '/api/v1/payment/get/', + '/api/v1/payment/list', '/api/v1/customer/token/get', '/api/v1/health', ] as $prefix From e70773eb5a3fd76d719a4f3a387ae0c86d48006a Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 16 Aug 2026 17:40:48 +0600 Subject: [PATCH 2/2] fix(web-api): pass string columns to xPDOQuery::select PHPStan stubs type select() as string-only; use comma-separated column lists in CheckoutMemberMap. --- .../src/Services/Catalog/CheckoutMemberMap.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php b/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php index 034af03b..62d25857 100644 --- a/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php +++ b/core/components/minishop3/src/Services/Catalog/CheckoutMemberMap.php @@ -41,10 +41,7 @@ public function paymentIdsByDelivery(): array { $c = $this->modx->newQuery(msPayment::class); $c->setClassAlias('msPayment'); - $c->select([ - 'msPayment.id AS payment_id', - 'Member.delivery_id AS delivery_id', - ]); + $c->select('msPayment.id AS payment_id, Member.delivery_id AS delivery_id'); $c->innerJoin( msDeliveryMember::class, 'Member', @@ -69,10 +66,7 @@ public function deliveryIdsByPayment(): array { $c = $this->modx->newQuery(msDelivery::class); $c->setClassAlias('msDelivery'); - $c->select([ - 'msDelivery.id AS delivery_id', - 'Member.payment_id AS payment_id', - ]); + $c->select('msDelivery.id AS delivery_id, Member.payment_id AS payment_id'); $c->innerJoin( msDeliveryMember::class, 'Member',