From 596abacd77eb89d7b3380c1d7066670082f438d0 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 17 Aug 2026 11:19:23 +0600 Subject: [PATCH] feat(order): lifecycle gate with ports, idempotency, and rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make OrderStatusService the non-draft status gate: optional transition allow-list, NullOrderLifecyclePorts for #589–#591, idempotent retries, and status rollback when after-event or a port fails. --- _build/elements/settings.php | 10 + .../minishop3/lexicon/en/default.inc.php | 3 + .../minishop3/lexicon/en/setting.inc.php | 4 + .../minishop3/lexicon/ru/default.inc.php | 3 + .../minishop3/lexicon/ru/setting.inc.php | 4 + .../src/Controllers/Payment/Payment.php | 29 +- .../minishop3/src/ServiceRegistry.php | 25 +- .../src/ServiceRegistryFactories.php | 7 +- .../Order/ManagerOrderMutationService.php | 19 +- .../Order/NullOrderLifecyclePorts.php | 28 ++ .../Order/OrderLifecyclePortsInterface.php | 40 ++ .../src/Services/Order/OrderStatusService.php | 138 +++++-- .../Order/OrderStatusTransitionPolicy.php | 89 ++++ .../Order/OrderStatusServiceLifecycleTest.php | 380 ++++++++++++++++++ .../Order/OrderStatusTransitionPolicyTest.php | 57 +++ 15 files changed, 781 insertions(+), 55 deletions(-) create mode 100644 core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php create mode 100644 core/components/minishop3/src/Services/Order/OrderLifecyclePortsInterface.php create mode 100644 core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php create mode 100644 core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php diff --git a/_build/elements/settings.php b/_build/elements/settings.php index c8c371cd..fa5958b2 100644 --- a/_build/elements/settings.php +++ b/_build/elements/settings.php @@ -296,6 +296,16 @@ 'xtype' => 'numberfield', 'area' => 'ms3_statuses', ], + 'ms3_status_sent' => [ + 'value' => 4, + 'xtype' => 'numberfield', + 'area' => 'ms3_statuses', + ], + 'ms3_order_status_transitions' => [ + 'value' => '', + 'xtype' => 'textfield', + 'area' => 'ms3_statuses', + ], 'ms3_customer_cancel_allowed_statuses' => [ 'value' => '2,3', 'xtype' => 'textfield', diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 756f3837..5621a333 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -193,6 +193,9 @@ $_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.'; $_lang['ms3_err_status_wrong'] = 'Invalid order status.'; $_lang['ms3_err_status_same'] = 'This status is already set.'; +$_lang['ms3_err_status_transition'] = 'This status transition is not allowed.'; +$_lang['ms3_err_status_transitions_invalid'] = 'Order status transition allow-list is invalid.'; +$_lang['ms3_err_status_rollback'] = 'Failed to roll back order status after a rejected transition.'; $_lang['ms3_err_register_globals'] = 'Error: php parameter register_globals must be disabled.'; $_lang['ms3_err_link_equal'] = 'You are trying to add product link to itself'; $_lang['ms3_err_no_link'] = 'Link type not found'; diff --git a/core/components/minishop3/lexicon/en/setting.inc.php b/core/components/minishop3/lexicon/en/setting.inc.php index a82d6193..892dbc97 100644 --- a/core/components/minishop3/lexicon/en/setting.inc.php +++ b/core/components/minishop3/lexicon/en/setting.inc.php @@ -139,6 +139,10 @@ $_lang['setting_ms3_status_paid_desc'] = 'What status to set after order payment'; $_lang['setting_ms3_status_canceled'] = 'Canceled order status ID'; $_lang['setting_ms3_status_canceled_desc'] = 'What status to set when canceling order'; +$_lang['setting_ms3_status_sent'] = 'Shipped / sent order status ID'; +$_lang['setting_ms3_status_sent_desc'] = 'Status ID treated as shipped for order lifecycle ports (default seed: 4).'; +$_lang['setting_ms3_order_status_transitions'] = 'Allowed order status transitions'; +$_lang['setting_ms3_order_status_transitions_desc'] = 'Optional allow-list of status edges in addition to final/fixed rules. Empty = no matrix (default final/fixed only). Format: CSV pairs from:to (e.g. 2:3,3:4,2:5) or JSON [[2,3],[3,4]].'; $_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Statuses from which customer can cancel order'; $_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'Comma-separated status IDs. Default: New and Paid (2,3). Empty = use ms3_status_new and ms3_status_paid.'; $_lang['setting_ms3_status_for_stat'] = 'Status IDs for statistics'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 26ab5683..ea117911 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -193,6 +193,9 @@ $_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.'; $_lang['ms3_err_status_wrong'] = 'Неверный статус заказа.'; $_lang['ms3_err_status_same'] = 'Этот статус уже установлен.'; +$_lang['ms3_err_status_transition'] = 'Такой переход статуса не разрешён.'; +$_lang['ms3_err_status_transitions_invalid'] = 'Некорректный allow-list переходов статусов заказа.'; +$_lang['ms3_err_status_rollback'] = 'Не удалось откатить статус заказа после отклонённого перехода.'; $_lang['ms3_err_register_globals'] = 'Ошибка: php параметр register_globals должен быть выключен.'; $_lang['ms3_err_link_equal'] = 'Вы пытаетесь добавить товару ссылку на самого себя'; $_lang['ms3_err_no_link'] = 'Тип связи не найден'; diff --git a/core/components/minishop3/lexicon/ru/setting.inc.php b/core/components/minishop3/lexicon/ru/setting.inc.php index 88678f60..a1aea93f 100644 --- a/core/components/minishop3/lexicon/ru/setting.inc.php +++ b/core/components/minishop3/lexicon/ru/setting.inc.php @@ -139,6 +139,10 @@ $_lang['setting_ms3_status_paid_desc'] = 'Какой статус нужно устанавливать после оплаты заказа'; $_lang['setting_ms3_status_canceled'] = 'ID статуса отмены заказа'; $_lang['setting_ms3_status_canceled_desc'] = 'Какой статус нужно устанавливать при отмене заказа'; +$_lang['setting_ms3_status_sent'] = 'ID статуса «Отправлен»'; +$_lang['setting_ms3_status_sent_desc'] = 'Статус, при котором срабатывает порт отгрузки в lifecycle (по умолчанию seed id 4).'; +$_lang['setting_ms3_order_status_transitions'] = 'Разрешённые переходы статусов заказа'; +$_lang['setting_ms3_order_status_transitions_desc'] = 'Опциональный allow-list рёбер поверх правил final/fixed. Пусто — только final/fixed. Формат: CSV пары from:to (например 2:3,3:4,2:5) или JSON [[2,3],[3,4]].'; $_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Статусы, из которых покупатель может отменить заказ'; $_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'ID статусов через запятую. По умолчанию: «Новый» и «Оплачен» (2,3). Пусто — использовать ms3_status_new и ms3_status_paid.'; $_lang['setting_ms3_status_for_stat'] = 'ID статусов для статистики'; diff --git a/core/components/minishop3/src/Controllers/Payment/Payment.php b/core/components/minishop3/src/Controllers/Payment/Payment.php index a0222b8b..38f21d4c 100644 --- a/core/components/minishop3/src/Controllers/Payment/Payment.php +++ b/core/components/minishop3/src/Controllers/Payment/Payment.php @@ -62,15 +62,32 @@ * } * * if ($data['status'] === 'succeeded') { - * $order->set('status_id', $this->getPaidStatusId()); - * $order->save(); - * return $this->success('Payment confirmed'); + * // Non-draft status changes must go through OrderStatusService (issue #592). + * // Prefer PaymentLifecycle (#590) when available; until then: + * $status = $this->modx->services->get('ms3_order_status'); + * $result = $status->change( + * (int) $order->get('id'), + * $this->getPaidStatusId(), + * false, + * ['idempotent' => true] + * ); + * return $result === true + * ? $this->success('Payment confirmed') + * : $this->error((string) $result); * } * * if ($data['status'] === 'canceled') { - * $order->set('status_id', $this->getCanceledStatusId()); - * $order->save(); - * return $this->error('Payment canceled'); + * $status = $this->modx->services->get('ms3_order_status'); + * $result = $status->change( + * (int) $order->get('id'), + * $this->getCanceledStatusId(), + * false, + * ['idempotent' => true] + * ); + * // ACK the webhook even when payment was canceled; distinguish transport vs business. + * return $result === true + * ? $this->success('Payment canceled') + * : $this->error((string) $result); * } * * return $this->error('Payment failed'); diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 296706fd..af78ac14 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -107,7 +107,7 @@ class ServiceRegistry 'ms3_order_number_generator', ], 'ms3_order_finalize' => ['ms3_order_number_generator'], - 'ms3_order_status' => ['ms3_order_log'], + 'ms3_order_status' => ['ms3_order_log', 'ms3_order_lifecycle_ports'], 'ms3_cart_mutation_handler' => [ 'ms3_order_draft_manager', 'ms3_cart_item_manager', @@ -256,6 +256,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Order\OrderLogService::class, 'interface' => null, ], + 'ms3_order_lifecycle_ports' => [ + 'class' => \MiniShop3\Services\Order\NullOrderLifecyclePorts::class, + 'interface' => \MiniShop3\Services\Order\OrderLifecyclePortsInterface::class, + ], 'ms3_order_status' => [ 'class' => \MiniShop3\Services\Order\OrderStatusService::class, 'interface' => null, @@ -688,19 +692,16 @@ protected function validateClass( return $fallbackClass; } - if ($requiredInterface) { - $interfaces = class_implements($className); - if (!in_array($requiredInterface, $interfaces ?: [])) { - $this->modx->log( - modX::LOG_LEVEL_ERROR, - "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, " - . 'using fallback' - ); - return $fallbackClass; - } + if ($requiredInterface && !is_a($className, $requiredInterface, true)) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, " + . 'using fallback' + ); + return $fallbackClass; } - if (!is_subclass_of($className, $fallbackClass)) { + if (!$requiredInterface && !is_subclass_of($className, $fallbackClass)) { $this->modx->log( modX::LOG_LEVEL_ERROR, "[MiniShop3 ServiceRegistry] Class '{$className}' must extend {$fallbackClass}, using fallback" diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 03585364..135e29b6 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -147,11 +147,16 @@ public static function map(): array ); }, + 'ms3_order_lifecycle_ports' => static function (modX $modx, object $services, string $class): object { + return new $class(); + }, + 'ms3_order_status' => static function (modX $modx, object $services, string $class): object { return new $class( $modx, self::ms3($modx), - $services->get('ms3_order_log') + $services->get('ms3_order_log'), + $services->get('ms3_order_lifecycle_ports') ); }, diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php index 769f417e..f681cfae 100644 --- a/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php +++ b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php @@ -201,12 +201,19 @@ public function update(array $params = []): array // Store old values for logging $oldStatusId = (int)$order->get('status_id'); + $pendingStatusId = array_key_exists('status_id', $params) + ? (int) $params['status_id'] + : null; // Get editable order fields from msModelField configuration $orderFields = $this->presenter->getModelFieldNames('msOrder'); $changedOrderFields = []; foreach ($orderFields as $field) { + // Non-draft status changes go only through OrderStatusService (#592). + if ($field === 'status_id') { + continue; + } if (array_key_exists($field, $params)) { $oldValue = $order->get($field); $newValue = $params[$field]; @@ -257,8 +264,7 @@ public function update(array $params = []): array return $this->error('Failed to update order', HttpStatus::INTERNAL_SERVER_ERROR); } - // Log order field changes (excluding status_id which is logged separately) - unset($changedOrderFields['status_id']); + // Log order field changes (status_id is logged by OrderStatusService) if (!empty($changedOrderFields)) { $this->getOrderLog()->addEntry( $id, @@ -325,15 +331,10 @@ public function update(array $params = []): array } // Handle status change via OrderStatusService (sends notifications) - $newStatusId = (int)$order->get('status_id'); - if ($oldStatusId !== $newStatusId) { - // Revert status to old value - OrderStatusService will change it properly - $order->set('status_id', $oldStatusId); - $order->save(); - + if ($pendingStatusId !== null && $pendingStatusId !== $oldStatusId) { /** @var OrderStatusService $orderStatusService */ $orderStatusService = $this->modx->services->get('ms3_order_status'); - $result = $orderStatusService->change((int)$order->get('id'), $newStatusId); + $result = $orderStatusService->change((int)$order->get('id'), $pendingStatusId); if ($result !== true) { return $this->error( diff --git a/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php b/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php new file mode 100644 index 00000000..bc4df83d --- /dev/null +++ b/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php @@ -0,0 +1,28 @@ +modx = $modx; $this->ms3 = $ms3; $this->orderLog = $orderLog; + $this->lifecyclePorts = $lifecyclePorts ?? new NullOrderLifecyclePorts(); $this->modx->lexicon->load('minishop3:default'); } @@ -70,15 +84,22 @@ public function getAllowedCancelStatusIds(): array } /** - * Switch order status + * Switch order status (single gate for non-draft transitions). * * @param int $orderId The id of msOrder * @param int $statusId The id of msOrderStatus * @param bool $skipNotifications Skip sending notifications (for admin finalization) + * @param array{idempotent?: bool} $options idempotent=true → same status is success no-op * @return bool|string True on success, error message on failure */ - public function change(int $orderId, int $statusId, bool $skipNotifications = false): bool|string - { + public function change( + int $orderId, + int $statusId, + bool $skipNotifications = false, + array $options = [] + ): bool|string { + $idempotent = !empty($options['idempotent']); + /** @var msOrder|null $msOrder */ $msOrder = $this->modx->getObject(msOrder::class, ['id' => $orderId]); if (!$msOrder) { @@ -95,26 +116,26 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa return $this->modx->lexicon('ms3_err_status_nf'); } + $storedStatusId = $msOrder->get('status_id'); + $previousStatusId = $storedStatusId !== null ? (int) $storedStatusId : null; + /** @var msOrderStatusModel|null $oldStatus */ - $oldStatus = $this->modx->getObject( - msOrderStatusModel::class, - ['id' => $msOrder->get('status_id'), 'active' => 1] - ); + $oldStatus = $previousStatusId !== null + ? $this->modx->getObject(msOrderStatusModel::class, ['id' => $previousStatusId]) + : null; - if ($oldStatus) { - $transitionError = $this->validateStatusTransition($oldStatus, $status); - if ($transitionError !== null) { - return $transitionError; - } + if ($previousStatusId === $statusId) { + return $idempotent ? true : $this->modx->lexicon('ms3_err_status_same'); } - if ($msOrder->get('status_id') == $statusId) { - return $this->modx->lexicon('ms3_err_status_same'); + $transitionError = $this->validateStatusTransition($oldStatus, $status); + if ($transitionError !== null) { + return $transitionError; } $eventParams = [ 'msOrder' => $msOrder, - 'old_status' => $oldStatus?->get('id'), + 'old_status' => $previousStatusId, 'status' => $statusId, ]; $response = $this->ms3->utils->invokeEvent('msOnBeforeChangeOrderStatus', $eventParams); @@ -134,8 +155,8 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa if (!$status) { return $this->modx->lexicon('ms3_err_status_nf'); } - if ($msOrder->get('status_id') == $statusId) { - return $this->modx->lexicon('ms3_err_status_same'); + if ($previousStatusId === $statusId) { + return $idempotent ? true : $this->modx->lexicon('ms3_err_status_same'); } $transitionError = $this->validateStatusTransition($oldStatus, $status); @@ -145,22 +166,26 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa } $msOrder->set('status_id', $statusId); - if (!$msOrder->save()) { return $this->modx->lexicon('ms3_err_unknown'); } - $this->orderLog->add($msOrder->get('id'), $statusId, 'status'); + $portError = $this->runLifecyclePorts($msOrder, $statusId, $previousStatusId); + if ($portError !== null) { + return $this->rollbackStatus($msOrder, $previousStatusId) ?? $portError; + } $response = $this->ms3->utils->invokeEvent('msOnChangeOrderStatus', [ 'msOrder' => $msOrder, - 'old_status' => $oldStatus?->get('id'), + 'old_status' => $previousStatusId, 'status' => $statusId, ]); if (!$response['success']) { - return $response['message']; + return $this->rollbackStatus($msOrder, $previousStatusId) ?? $response['message']; } + $this->orderLog->add($msOrder->get('id'), $statusId, 'status'); + // Send notifications via NotificationManager (unless skipped) // Use output buffering to prevent any stray output from Fenom/pdoTools if (!$skipNotifications) { @@ -173,7 +198,7 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa } /** - * Validate transition from old status to new (final/fixed rules). + * Validate transition: final/fixed defaults + optional allow-list (ms3_order_status_transitions). */ protected function validateStatusTransition( ?msOrderStatusModel $oldStatus, @@ -191,9 +216,68 @@ protected function validateStatusTransition( return $this->modx->lexicon('ms3_err_status_fixed'); } + $edges = OrderStatusTransitionPolicy::resolve( + $this->modx->getOption('ms3_order_status_transitions', null, '') + ); + if ($edges['mode'] === OrderStatusTransitionPolicy::MODE_INVALID) { + return $this->modx->lexicon('ms3_err_status_transitions_invalid'); + } + if ( + $edges['mode'] === OrderStatusTransitionPolicy::MODE_ON + && !isset($edges['edges'][(int) $oldStatus->get('id')][(int) $newStatus->get('id')]) + ) { + return $this->modx->lexicon('ms3_err_status_transition'); + } + + return null; + } + + /** + * Invoke semantic lifecycle ports when the target matches configured status ids. + */ + protected function runLifecyclePorts(msOrder $order, int $statusId, ?int $previousStatusId): ?string + { + $paidId = (int) $this->modx->getOption('ms3_status_paid', null, 3); + $canceledId = (int) $this->modx->getOption('ms3_status_canceled', null, 5); + $sentId = (int) $this->modx->getOption('ms3_status_sent', null, 4); + + if ($statusId === $paidId) { + return $this->lifecyclePorts->onOrderBecamePaid($order, $previousStatusId); + } + if ($statusId === $canceledId) { + return $this->lifecyclePorts->onOrderCancelled($order, $previousStatusId); + } + if ($statusId === $sentId) { + return $this->lifecyclePorts->onOrderShipped($order, $previousStatusId); + } + return null; } + /** + * Restore previous status_id after failed after-event or lifecycle port. + * + * @return string|null Lexicon/error when rollback persist fails + */ + protected function rollbackStatus(msOrder $order, ?int $previousStatusId): ?string + { + $order->set('status_id', $previousStatusId); + if ($order->save()) { + return null; + } + + $this->modx->log( + modX::LOG_LEVEL_ERROR, + sprintf( + '[MiniShop3] Failed to rollback order #%s status to %s', + (string) $order->get('id'), + $previousStatusId === null ? 'null' : (string) $previousStatusId + ) + ); + + return $this->modx->lexicon('ms3_err_status_rollback'); + } + /** * Send notifications for status change * diff --git a/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php b/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php new file mode 100644 index 00000000..8d1eb13c --- /dev/null +++ b/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php @@ -0,0 +1,89 @@ +>} + */ + public static function resolve(mixed $raw): array + { + if ($raw === null) { + return ['mode' => self::MODE_OFF, 'edges' => []]; + } + + if (is_array($raw)) { + return ['mode' => self::MODE_ON, 'edges' => self::fromPairList($raw)]; + } + + $value = trim((string) $raw); + if ($value === '') { + return ['mode' => self::MODE_OFF, 'edges' => []]; + } + + if (str_starts_with($value, '[')) { + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return ['mode' => self::MODE_INVALID, 'edges' => []]; + } + + return ['mode' => self::MODE_ON, 'edges' => self::fromPairList($decoded)]; + } + + $pairs = []; + foreach (array_filter(array_map('trim', explode(',', $value))) as $pair) { + $parts = array_map('trim', explode(':', $pair, 2)); + if (count($parts) !== 2) { + return ['mode' => self::MODE_INVALID, 'edges' => []]; + } + $pairs[] = $parts; + } + + $edges = self::fromPairList($pairs); + if ($edges === []) { + return ['mode' => self::MODE_INVALID, 'edges' => []]; + } + + return ['mode' => self::MODE_ON, 'edges' => $edges]; + } + + /** + * @param array $pairs + * @return array> + */ + private static function fromPairList(array $pairs): array + { + $edges = []; + foreach ($pairs as $pair) { + if (!is_array($pair) || count($pair) < 2) { + continue; + } + $from = (int) $pair[0]; + $to = (int) $pair[1]; + if ($from < 1 || $to < 1) { + continue; + } + $edges[$from][$to] = true; + } + + return $edges; + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php new file mode 100644 index 00000000..67077110 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php @@ -0,0 +1,380 @@ +makeHarness(statusId: 2); + $log = $this->recordingLog(); + $events = []; + + $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events); + $result = $service->change(10, 2, true, ['idempotent' => true]); + + self::assertTrue($result); + self::assertSame([], $log->entries); + self::assertSame([], $events); + } + + public function testSameStatusWithoutIdempotentReturnsError(): void + { + $harness = $this->makeHarness(statusId: 2); + $log = $this->recordingLog(); + $events = []; + + $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events); + $result = $service->change(10, 2); + + self::assertSame('ms3_err_status_same', $result); + self::assertSame([], $log->entries); + } + + public function testAllowListRejectsUnknownEdgeWithoutSave(): void + { + $harness = $this->makeHarness(statusId: 2, options: [ + 'ms3_order_status_transitions' => '2:3', + ]); + $log = $this->recordingLog(); + $events = []; + + $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events); + $result = $service->change(10, 4); + + self::assertSame('ms3_err_status_transition', $result); + self::assertSame(2, $harness['order']->get('status_id')); + self::assertSame([], $log->entries); + self::assertSame([], $events); + } + + public function testInvalidAllowListConfigRejectedWithoutSave(): void + { + $harness = $this->makeHarness(statusId: 2, options: [ + 'ms3_order_status_transitions' => '[broken', + ]); + $log = $this->recordingLog(); + $events = []; + + $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events); + $result = $service->change(10, 3); + + self::assertSame('ms3_err_status_transitions_invalid', $result); + self::assertSame(2, $harness['order']->get('status_id')); + self::assertSame([], $log->entries); + } + + public function testCancelAndShippedPortsAreInvoked(): void + { + $ports = new class implements OrderLifecyclePortsInterface { + public int $cancelCalls = 0; + public int $shipCalls = 0; + + public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string + { + return null; + } + + public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string + { + ++$this->cancelCalls; + + return null; + } + + public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string + { + ++$this->shipCalls; + + return null; + } + }; + + $cancelHarness = $this->makeHarness(statusId: 2); + $cancelLog = $this->recordingLog(); + $cancelEvents = []; + $cancelService = $this->makeService($cancelHarness, $cancelLog, $ports, $cancelEvents); + self::assertTrue($cancelService->change(10, 5, true)); + self::assertSame(1, $ports->cancelCalls); + + $shipHarness = $this->makeHarness(statusId: 2); + $shipLog = $this->recordingLog(); + $shipEvents = []; + $shipService = $this->makeService($shipHarness, $shipLog, $ports, $shipEvents); + self::assertTrue($shipService->change(10, 4, true)); + self::assertSame(1, $ports->shipCalls); + } + + public function testAfterEventFailureRollsBackStatusAndSkipsLog(): void + { + $harness = $this->makeHarness(statusId: 2); + $log = $this->recordingLog(); + $events = []; + + $service = $this->makeService( + $harness, + $log, + new NullOrderLifecyclePorts(), + $events, + afterFail: true + ); + $result = $service->change(10, 3, true); + + self::assertSame('after failed', $result); + self::assertSame(2, $harness['order']->get('status_id')); + self::assertSame([], $log->entries); + self::assertSame(['msOnBeforeChangeOrderStatus', 'msOnChangeOrderStatus'], $events); + } + + public function testPaidPortRunsAndFailureRollsBack(): void + { + $harness = $this->makeHarness(statusId: 2); + $log = $this->recordingLog(); + $events = []; + $ports = new class implements OrderLifecyclePortsInterface { + public int $paidCalls = 0; + + public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string + { + ++$this->paidCalls; + + return 'port failed'; + } + + public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string + { + return null; + } + + public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string + { + return null; + } + }; + + $service = $this->makeService($harness, $log, $ports, $events); + $result = $service->change(10, 3, true); + + self::assertSame('port failed', $result); + self::assertSame(1, $ports->paidCalls); + self::assertSame(2, $harness['order']->get('status_id')); + self::assertSame([], $log->entries); + self::assertSame(['msOnBeforeChangeOrderStatus'], $events); + } + + public function testSuccessfulPaidTransitionLogsAndInvokesPort(): void + { + $harness = $this->makeHarness(statusId: 2); + $log = $this->recordingLog(); + $events = []; + $ports = new class implements OrderLifecyclePortsInterface { + public int $paidCalls = 0; + + public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string + { + ++$this->paidCalls; + + return null; + } + + public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string + { + return null; + } + + public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string + { + return null; + } + }; + + $service = $this->makeService($harness, $log, $ports, $events); + $result = $service->change(10, 3, true); + + self::assertTrue($result); + self::assertSame(1, $ports->paidCalls); + self::assertSame(3, $harness['order']->get('status_id')); + self::assertSame([[10, 3, 'status']], $log->entries); + self::assertSame(['msOnBeforeChangeOrderStatus', 'msOnChangeOrderStatus'], $events); + } + + /** + * @param array $options + * @return array{order: RecordingMsOrder, statuses: array, options: array} + */ + private function makeHarness(int $statusId, array $options = []): array + { + $order = new RecordingMsOrder([ + 'id' => 10, + 'status_id' => $statusId, + 'context' => 'web', + ]); + + $statuses = [ + 2 => $this->makeStatus(2, final: false, fixed: false, position: 2), + 3 => $this->makeStatus(3, final: false, fixed: true, position: 3), + 4 => $this->makeStatus(4, final: true, fixed: true, position: 4), + 5 => $this->makeStatus(5, final: true, fixed: false, position: 5), + ]; + + return [ + 'order' => $order, + 'statuses' => $statuses, + 'options' => array_merge([ + 'ms3_status_paid' => 3, + 'ms3_status_canceled' => 5, + 'ms3_status_sent' => 4, + 'ms3_order_status_transitions' => '', + ], $options), + ]; + } + + private function makeStatus(int $id, bool $final, bool $fixed, int $position): object + { + return new class($id, $final, $fixed, $position) extends msOrderStatus { + public function __construct( + private int $statusId, + private bool $isFinal, + private bool $isFixed, + private int $pos + ) { + } + + public function get($k, $format = null, $formatType = '') + { + return match ($k) { + 'id' => $this->statusId, + 'final' => $this->isFinal ? 1 : 0, + 'fixed' => $this->isFixed ? 1 : 0, + 'position' => $this->pos, + default => null, + }; + } + }; + } + + /** + * @return OrderLogService&object{entries: list} + */ + private function recordingLog(): OrderLogService + { + return new class extends OrderLogService { + /** @var list */ + public array $entries = []; + + public function __construct() + { + } + + public function add(int $order_id, mixed $entry, string $action, bool $visible = true): bool + { + $this->entries[] = [$order_id, $entry, $action]; + + return true; + } + }; + } + + /** + * @param array{order: RecordingMsOrder, statuses: array, options: array} $harness + * @param list $events + */ + private function makeService( + array $harness, + OrderLogService $log, + OrderLifecyclePortsInterface $ports, + array &$events, + bool $afterFail = false + ): OrderStatusService { + $modx = new class($harness, $afterFail) extends modX { + /** @var array{order: RecordingMsOrder, statuses: array, options: array} */ + private array $harness; + private bool $afterFail; + + public function __construct(array $harness, bool $afterFail) + { + parent::__construct(); + $this->harness = $harness; + $this->afterFail = $afterFail; + } + + public function getOption(string $key, $options = null, $default = null) + { + return $this->harness['options'][$key] ?? $default; + } + + public function switchContext($contextKey, $force = false) + { + return true; + } + + public function getObject($className, $criteria = null, $cacheFlag = true) + { + if ($className === msOrder::class || $className === RecordingMsOrder::class) { + $id = is_array($criteria) ? (int) ($criteria['id'] ?? 0) : (int) $criteria; + return $id === (int) $this->harness['order']->get('id') + ? $this->harness['order'] + : null; + } + + if ($className === msOrderStatus::class || is_a($className, msOrderStatus::class, true)) { + $id = is_array($criteria) ? (int) ($criteria['id'] ?? 0) : (int) $criteria; + return $this->harness['statuses'][$id] ?? null; + } + + return null; + } + }; + + $ms3 = $this->createMock(MiniShop3::class); + $ms3->method('initialize')->willReturn(true); + + $utils = new class($events, $afterFail) { + /** @var list */ + private array $events; + private bool $afterFail; + + public function __construct(array &$events, bool $afterFail) + { + $this->events = &$events; + $this->afterFail = $afterFail; + } + + public function invokeEvent(string $eventName, array $params = [], $glue = '
'): array + { + $this->events[] = $eventName; + if ($eventName === 'msOnChangeOrderStatus' && $this->afterFail) { + return ['success' => false, 'message' => 'after failed', 'data' => []]; + } + + return ['success' => true, 'message' => '', 'data' => $params]; + } + }; + $ms3->utils = $utils; + + return new OrderStatusService($modx, $ms3, $log, $ports); + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php new file mode 100644 index 00000000..4f511ee3 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php @@ -0,0 +1,57 @@ +