From 6290f1768713a925a8e0985b3ae2633f9555dfe0 Mon Sep 17 00:00:00 2001 From: Can Demiralp Date: Wed, 24 Sep 2025 14:15:57 +0200 Subject: [PATCH 1/4] [ECP-9751] Implement a cronjob to submit pending events --- ... => AnalyticsEventRepositoryInterface.php} | 2 +- Api/Data/AnalyticsEventInterface.php | 30 ++- Api/Data/AnalyticsEventStatusEnum.php | 20 ++ Api/Data/AnalyticsEventTypeEnum.php | 20 ++ .../AnalyticsEventProviderInterface.php | 34 +++ .../PendingErrorsAnalyticsEventsProvider.php | 60 +++++ .../PendingInfoAnalyticsEventsProvider.php | 62 +++++ Cron/SubmitAnalyticsEvents.php | 133 ++++++++++ Helper/CheckoutAnalytics.php | 231 +++++++----------- Model/AnalyticsEvent.php | 70 +++++- ...itory.php => AnalyticsEventRepository.php} | 4 +- .../AnalyticsEvent/Collection.php | 42 ++++ Observer/ProcessAnalyticsEvent.php | 8 +- etc/crontab.xml | 3 + etc/db_schema.xml | 13 +- etc/db_schema_whitelist.json | 7 +- etc/di.xml | 16 +- 17 files changed, 594 insertions(+), 161 deletions(-) rename Api/{AdyenAnalyticsRepositoryInterface.php => AnalyticsEventRepositoryInterface.php} (89%) create mode 100644 Api/Data/AnalyticsEventStatusEnum.php create mode 100644 Api/Data/AnalyticsEventTypeEnum.php create mode 100644 Cron/Providers/AnalyticsEventProviderInterface.php create mode 100644 Cron/Providers/PendingErrorsAnalyticsEventsProvider.php create mode 100644 Cron/Providers/PendingInfoAnalyticsEventsProvider.php create mode 100644 Cron/SubmitAnalyticsEvents.php rename Model/{AdyenAnalyticsRepository.php => AnalyticsEventRepository.php} (92%) diff --git a/Api/AdyenAnalyticsRepositoryInterface.php b/Api/AnalyticsEventRepositoryInterface.php similarity index 89% rename from Api/AdyenAnalyticsRepositoryInterface.php rename to Api/AnalyticsEventRepositoryInterface.php index 641e6eae6f..984a30b012 100644 --- a/Api/AdyenAnalyticsRepositoryInterface.php +++ b/Api/AnalyticsEventRepositoryInterface.php @@ -3,7 +3,7 @@ use Adyen\Payment\Api\Data\AnalyticsEventInterface; -interface AdyenAnalyticsRepositoryInterface +interface AnalyticsEventRepositoryInterface { public function save(AnalyticsEventInterface $analyticsEvent): AnalyticsEventInterface; diff --git a/Api/Data/AnalyticsEventInterface.php b/Api/Data/AnalyticsEventInterface.php index 1f9d18685a..27f7593c89 100644 --- a/Api/Data/AnalyticsEventInterface.php +++ b/Api/Data/AnalyticsEventInterface.php @@ -23,10 +23,14 @@ interface AnalyticsEventInterface const TYPE = 'type'; const TOPIC = 'topic'; const MESSAGE = 'message'; + const ERROR_TYPE = 'error_type'; + const ERROR_CODE = 'error_code'; const ERROR_COUNT = 'error_count'; const STATUS = 'status'; const CREATED_AT = 'created_at'; const UPDATED_AT = 'updated_at'; + const SCHEDULED_PROCESSING_TIME = 'scheduled_processing_time'; + const MAX_ERROR_COUNT = 5; public function getEntityId(); @@ -52,19 +56,33 @@ public function getMessage(): ?string; public function setMessage(?string $message = null): AnalyticsEventInterface; + public function getErrorType(): ?string; + + public function setErrorType(?string $errorType = null): AnalyticsEventInterface; + + public function getErrorCode(): ?string; + + public function setErrorCode(?string $errorCode = null): AnalyticsEventInterface; + public function getErrorCount(): int; public function setErrorCount(int $errorCount): AnalyticsEventInterface; - public function getStatus(): int; + public function getStatus(): string; + + public function setStatus(string $status): AnalyticsEventInterface; + + public function getCreatedAt(): string; + + public function setCreatedAt(string $createdAt): AnalyticsEventInterface; - public function setStatus(int $status): AnalyticsEventInterface; + public function getCreatedAtTimestamp(): int; - public function getCreatedAt(): DateTime; + public function getUpdatedAt(): ?string; - public function setCreatedAt(DateTime $createdAt): AnalyticsEventInterface; + public function setUpdatedAt(?string $updatedAt = null): AnalyticsEventInterface; - public function getUpdatedAt(): ?DateTime; + public function getScheduledProcessingTime(): ?string; - public function setUpdatedAt(?DateTime $updatedAt = null): AnalyticsEventInterface; + public function setScheduledProcessingTime(?string $scheduledProcessingTime = null): AnalyticsEventInterface; } diff --git a/Api/Data/AnalyticsEventStatusEnum.php b/Api/Data/AnalyticsEventStatusEnum.php new file mode 100644 index 0000000000..9940dc48a6 --- /dev/null +++ b/Api/Data/AnalyticsEventStatusEnum.php @@ -0,0 +1,20 @@ + + */ + +namespace Adyen\Payment\Api\Data; + +enum AnalyticsEventStatusEnum: int +{ + case PENDING = 0; + case PROCESSING = 1; + case DONE = 2; +} diff --git a/Api/Data/AnalyticsEventTypeEnum.php b/Api/Data/AnalyticsEventTypeEnum.php new file mode 100644 index 0000000000..b78d663684 --- /dev/null +++ b/Api/Data/AnalyticsEventTypeEnum.php @@ -0,0 +1,20 @@ + + */ + +namespace Adyen\Payment\Api\Data; + +enum AnalyticsEventTypeEnum: string +{ + case EXPECTED_START = 'expectedStart'; + case EXPECTED_END = 'expectedEnd'; + case UNEXPECTED_END = 'unexpectedEnd'; +} diff --git a/Cron/Providers/AnalyticsEventProviderInterface.php b/Cron/Providers/AnalyticsEventProviderInterface.php new file mode 100644 index 0000000000..193b7b79b6 --- /dev/null +++ b/Cron/Providers/AnalyticsEventProviderInterface.php @@ -0,0 +1,34 @@ + + */ + +namespace Adyen\Payment\Cron\Providers; + +use Adyen\Payment\Api\Data\AnalyticsEventInterface; + +interface AnalyticsEventProviderInterface +{ + const BATCH_SIZE = 1000; + + /** + * @return AnalyticsEventInterface[] + */ + public function provide(): array; + + /** + * @return string + */ + public function getAnalyticsContext(): string; + + /** + * @return string + */ + public function getProviderName(): string; +} diff --git a/Cron/Providers/PendingErrorsAnalyticsEventsProvider.php b/Cron/Providers/PendingErrorsAnalyticsEventsProvider.php new file mode 100644 index 0000000000..82e63f812d --- /dev/null +++ b/Cron/Providers/PendingErrorsAnalyticsEventsProvider.php @@ -0,0 +1,60 @@ + + */ + +namespace Adyen\Payment\Cron\Providers; + +use Adyen\AdyenException; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; +use Adyen\Payment\Api\Data\AnalyticsEventTypeEnum; +use Adyen\Payment\Helper\CheckoutAnalytics; +use Adyen\Payment\Model\ResourceModel\AnalyticsEvent\Collection as AnalyticsEventCollection; +use Adyen\Payment\Model\ResourceModel\AnalyticsEvent\CollectionFactory as AnalyticsEventCollectionFactory; + +class PendingErrorsAnalyticsEventsProvider implements AnalyticsEventProviderInterface +{ + const PROVIDER_NAME = 'Pending analytics events for `errors` context'; + + public function __construct( + private readonly AnalyticsEventCollectionFactory $analyticsEventCollectionFactory + ) {} + + /** + * @return AnalyticsEventInterface[] + * @throws AdyenException + */ + public function provide(): array + { + $analyticsEventCollection = $this->analyticsEventCollectionFactory->create(); + + /** @var AnalyticsEventCollection $analyticsEventCollection */ + $analyticsEventCollection = $analyticsEventCollection->pendingAnalyticsEvents([ + AnalyticsEventTypeEnum::UNEXPECTED_END + ]); + + return $analyticsEventCollection->getItems(); + } + + /** + * @return string + */ + public function getProviderName(): string + { + return self::PROVIDER_NAME; + } + + /** + * @return string + */ + public function getAnalyticsContext(): string + { + return CheckoutAnalytics::CONTEXT_TYPE_ERRORS; + } +} diff --git a/Cron/Providers/PendingInfoAnalyticsEventsProvider.php b/Cron/Providers/PendingInfoAnalyticsEventsProvider.php new file mode 100644 index 0000000000..62ee16bca9 --- /dev/null +++ b/Cron/Providers/PendingInfoAnalyticsEventsProvider.php @@ -0,0 +1,62 @@ + + */ + +namespace Adyen\Payment\Cron\Providers; + +use Adyen\AdyenException; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; +use Adyen\Payment\Api\Data\AnalyticsEventTypeEnum; +use Adyen\Payment\Helper\CheckoutAnalytics; +use Adyen\Payment\Model\ResourceModel\AnalyticsEvent\Collection as AnalyticsEventCollection; +use Adyen\Payment\Model\ResourceModel\AnalyticsEvent\CollectionFactory as AnalyticsEventCollectionFactory; + +class PendingInfoAnalyticsEventsProvider implements AnalyticsEventProviderInterface +{ + const PROVIDER_NAME = 'Pending analytics events for `info` context'; + + public function __construct( + private readonly AnalyticsEventCollectionFactory $analyticsEventCollectionFactory + ) {} + + /** + * @return AnalyticsEventInterface[] + * @throws AdyenException + */ + public function provide(): array + { + $analyticsEventCollection = $this->analyticsEventCollectionFactory->create(); + + /** @var AnalyticsEventCollection $analyticsEventCollection */ + $analyticsEventCollection = $analyticsEventCollection->pendingAnalyticsEvents([ + AnalyticsEventTypeEnum::EXPECTED_START, + AnalyticsEventTypeEnum::EXPECTED_END, + AnalyticsEventTypeEnum::UNEXPECTED_END + ]); + + return $analyticsEventCollection->getItems(); + } + + /** + * @return string + */ + public function getProviderName(): string + { + return self::PROVIDER_NAME; + } + + /** + * @return string + */ + public function getAnalyticsContext(): string + { + return CheckoutAnalytics::CONTEXT_TYPE_INFO; + } +} diff --git a/Cron/SubmitAnalyticsEvents.php b/Cron/SubmitAnalyticsEvents.php new file mode 100644 index 0000000000..32938bdd01 --- /dev/null +++ b/Cron/SubmitAnalyticsEvents.php @@ -0,0 +1,133 @@ + + */ + +namespace Adyen\Payment\Cron; + +use Adyen\AdyenException; +use Adyen\Payment\Api\AnalyticsEventRepositoryInterface; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; +use Adyen\Payment\Api\Data\AnalyticsEventInterfaceFactory; +use Adyen\Payment\Api\Data\AnalyticsEventStatusEnum; +use Adyen\Payment\Api\Data\AnalyticsEventTypeEnum; +use Adyen\Payment\Cron\Providers\AnalyticsEventProviderInterface; +use Adyen\Payment\Helper\CheckoutAnalytics; +use Adyen\Payment\Helper\Util\Uuid; +use Exception; + +class SubmitAnalyticsEvents +{ + private ?string $checkoutAttemptId = null; + + /** + * @param AnalyticsEventProviderInterface[] $providers + * @param CheckoutAnalytics $checkoutAnalyticsHelper + * @param AnalyticsEventInterfaceFactory $analyticsEventFactory + * @param AnalyticsEventRepositoryInterface $analyticsEventRepository + */ + public function __construct( + protected readonly array $providers, + protected readonly CheckoutAnalytics $checkoutAnalyticsHelper, + protected readonly AnalyticsEventInterfaceFactory $analyticsEventFactory, + protected readonly AnalyticsEventRepositoryInterface $analyticsEventRepository, + ) { } + + public function execute(): void + { + $this->createTestData(); + + try { + foreach ($this->providers as $provider) { + $analyticsEvents = array_values($provider->provide()); + $context = $provider->getAnalyticsContext(); + $numberOfEvents = count($analyticsEvents); + + if ($numberOfEvents > 0) { + $checkoutAttemptId = $this->getCheckoutAttemptId(); + $maxNumber = CheckoutAnalytics::CONTEXT_MAX_ITEMS[$context]; + $subsetOfEvents = []; + + for ($i = 0; $i < count($analyticsEvents); $i++) { + $event = $analyticsEvents[$i]; + $event->setStatus(AnalyticsEventStatusEnum::PROCESSING->value); + $event = $this->analyticsEventRepository->save($event); + + $subsetOfEvents[] = $event; + + if (count($subsetOfEvents) === $maxNumber || (($numberOfEvents - $i + 1) < $maxNumber)) { + $response = $this->checkoutAnalyticsHelper->sendAnalytics( + $checkoutAttemptId, + $subsetOfEvents, + $context + ); + + foreach ($subsetOfEvents as $subsetOfEvent) { + if (isset($response['error'])) { + $subsetOfEvent->setErrorCount($subsetOfEvent->getErrorCount() + 1); + + if ($subsetOfEvent->getErrorCount() === AnalyticsEventInterface::MAX_ERROR_COUNT) { + $subsetOfEvent->setScheduledProcessingTime(); + $subsetOfEvent->setStatus(AnalyticsEventStatusEnum::DONE->value); + } else { + $nextScheduledTime = date( + 'Y-m-d H:i:s', + time() + (60 * 60 * $subsetOfEvent->getErrorCount() / 2) + ); + $subsetOfEvent->setScheduledProcessingTime($nextScheduledTime); + $subsetOfEvent->setStatus(AnalyticsEventStatusEnum::PENDING->value); + } + } else { + $subsetOfEvent->setStatus(AnalyticsEventStatusEnum::DONE->value); + } + + $this->analyticsEventRepository->save($subsetOfEvent); + } + + $subsetOfEvents = []; + } + } + } + } + } catch (Exception $e) { + //TODO:: Handle unexpected cases + } + } + + /** + * @throws AdyenException + */ + private function getCheckoutAttemptId(): string + { + if (empty($this->checkoutAttemptId)) { + $this->checkoutAttemptId = $this->checkoutAnalyticsHelper->initiateCheckoutAttempt(); + } + + return $this->checkoutAttemptId; + } + + // TODO:: Remove this test method before merging the PR! + private function createTestData() + { + $counter = 100; + + for ($i = 0; $i < $counter; $i++) { + /** @var AnalyticsEventInterface $analyticsEvent */ + $analyticsEvent = $this->analyticsEventFactory->create(); + + $analyticsEvent->setRelationId('MOCK_RELATION_ID'); + $analyticsEvent->setUuid(Uuid::generateV4()); + $analyticsEvent->setType(AnalyticsEventTypeEnum::EXPECTED_START->value); + $analyticsEvent->setTopic('MOCK_TOPIC'); + $analyticsEvent->setStatus(AnalyticsEventStatusEnum::PENDING->value); + + $this->analyticsEventRepository->save($analyticsEvent); + } + } +} diff --git a/Helper/CheckoutAnalytics.php b/Helper/CheckoutAnalytics.php index 4de7c8b233..fcb5adb232 100644 --- a/Helper/CheckoutAnalytics.php +++ b/Helper/CheckoutAnalytics.php @@ -3,7 +3,7 @@ * * Adyen Payment module (https://www.adyen.com/) * - * Copyright (c) 2024 Adyen N.V. (https://www.adyen.com/) + * Copyright (c) 2025 Adyen N.V. (https://www.adyen.com/) * See LICENSE.txt for license details. * * Author: Adyen @@ -12,10 +12,11 @@ namespace Adyen\Payment\Helper; use Adyen\AdyenException; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; use Adyen\Payment\Logger\AdyenLogger; use Exception; -use Magento\Framework\Exception\InvalidArgumentException; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\ValidatorException; use Magento\Framework\HTTP\ClientInterface; use Magento\Store\Model\StoreManagerInterface; @@ -28,6 +29,17 @@ class CheckoutAnalytics const CHECKOUT_ATTEMPT_ID = 'checkoutAttemptId'; const FLAVOR_COMPONENT = 'component'; const INTEGRATOR_ADYEN = 'Adyen'; + const PLUGIN_ADOBE_COMMERCE = 'adobeCommerce'; + const CHANNEL_WEB = 'Web'; + const PLATFORM_WEB = 'Web'; + const CONTEXT_TYPE_INFO = 'info'; + const CONTEXT_TYPE_LOGS = 'logs'; + const CONTEXT_TYPE_ERRORS = 'errors'; + const CONTEXT_MAX_ITEMS = [ + self::CONTEXT_TYPE_INFO => 50, + self::CONTEXT_TYPE_LOGS => 10, + self::CONTEXT_TYPE_ERRORS => 5 + ]; /** * @param Config $configHelper @@ -47,25 +59,25 @@ public function __construct( /** * Makes the initial API call to CheckoutAnalytics to obtain checkoutAttemptId * - * @return string|null + * @return string + * @throws AdyenException */ - public function initiateCheckoutAttempt(): ?string + public function initiateCheckoutAttempt(): string { try { $request = $this->buildInitiateCheckoutRequest(); $endpoint = $this->getInitiateAnalyticsUrl(); $response = $this->sendRequest($endpoint, $request); + $this->validateInitiateCheckoutAttemptResponse($response); - if ($this->validateInitiateCheckoutAttemptResponse($response)) { - return $response[self::CHECKOUT_ATTEMPT_ID]; - } + return $response[self::CHECKOUT_ATTEMPT_ID]; } catch (Exception $exception) { $errorMessage = __('Error while initiating checkout attempt: %s.', $exception->getMessage()); $this->adyenLogger->error($errorMessage); - } - return null; + throw new AdyenException($errorMessage); + } } /** @@ -73,27 +85,31 @@ public function initiateCheckoutAttempt(): ?string * * @param string $checkoutAttemptId * @param array $events - * @param string|null $channel - * @param string|null $platform - * @return void + * @param string $context + * @return array|null */ public function sendAnalytics( string $checkoutAttemptId, array $events, - ?string $channel = null, - ?string $platform = null - ): void { + string $context + ): ?array { try { - $request = $this->buildSendAnalyticsRequest($events, $channel, $platform); + $this->validateEventsAndContext($events, $context); + + $request = $this->buildSendAnalyticsRequest($events, $context); $endpoint = $this->getSendAnalyticsUrl($checkoutAttemptId); - $this->sendRequest($endpoint, $request); + + return $this->sendRequest($endpoint, $request); } catch (Exception $exception) { $errorMessage = __('Error while sending checkout analytic metrics: %s', $exception->getMessage()); $this->adyenLogger->error($errorMessage); + + return [ + 'error' => $errorMessage + ]; } } - /** * Builds the endpoint URL for sending analytics messages to CheckoutAnalytics * @@ -124,79 +140,49 @@ private function getSendAnalyticsUrl(string $checkoutAttemptId): string /** * Builds the request for sending analytics messages to CheckoutAnalytics * - * @param array $events - * @param string|null $channel - * @param string|null $platform + * @param AnalyticsEventInterface[] $events + * @param string $context Type of the analytics event [info, errors, logs] * @return array - * @throws InvalidArgumentException */ - // Replace buildSendAnalyticsRequest() with this private function buildSendAnalyticsRequest( array $events, - ?string $channel = null, - ?string $platform = null + string $context ): array { - if (empty($events)) { - throw new InvalidArgumentException(__('Events array cannot be empty!')); - } - - $info = []; - $errors = []; + $items = []; foreach ($events as $event) { - $createdAt = $this->getField($event, 'createdAt'); - $uuid = (string)$this->getField($event, 'uuid'); - $topic = (string)$this->getField($event, 'topic'); - $type = (string)$this->getField($event, 'type'); - $relationId = (string)$this->getField($event, 'relationId'); - - // Validate required bits (per schema mapping) - if ($createdAt === null || $uuid === '' || $topic === '' || $type === '' || $relationId === '') { - // Skip malformed event instead of failing the batch - continue; - } - - $timestampMs = $this->toUnixMillisString($createdAt); - - // INFO: cap at 50 - if (count($info) < 50) { - $info[] = [ - 'timestamp' => $timestampMs, - 'type' => $type, - 'target' => $relationId, - 'id' => $uuid, - 'component' => $topic, - ]; + // Generic fields + $contextPayload = [ + 'timestamp' => strval($event->getCreatedAtTimestamp() * 1000), + 'component' => $event->getTopic(), + 'id' => $event->getUuid() + ]; + + // Context specific fields + switch ($context) { + case self::CONTEXT_TYPE_INFO: + $contextPayload['type'] = $event->getType(); + $contextPayload['target'] = $event->getRelationId(); + break; + case self::CONTEXT_TYPE_LOGS: + $contextPayload['type'] = $event->getType(); + $contextPayload['message'] = $event->getMessage(); + break; + case self::CONTEXT_TYPE_ERRORS: + $contextPayload['message'] = $event->getMessage(); + $contextPayload['errorType'] = $event->getErrorType(); + $contextPayload['code'] = $event->getErrorCode(); + break; } - // ERRORS: only for unexpectedEnd, cap at 5 - if ($type === 'unexpectedEnd' && count($errors) < 5) { - $errors[] = [ - 'timestamp' => $timestampMs, - 'id' => $uuid, - 'component' => $topic, - 'errorType' => 'Plugin', - ]; - } - } - - if (empty($info) && empty($errors)) { - throw new InvalidArgumentException(__('No valid analytics events to send!')); + $items[] = $contextPayload; } - $request = [ - 'channel' => $channel ?? 'Web', - 'platform' => $platform ?? 'Web', + return [ + 'channel' => self::CHANNEL_WEB, + 'platform' => self::PLATFORM_WEB, + $context => $items ]; - - if (!empty($info)) { - $request['info'] = $info; - } - if (!empty($errors)) { - $request['errors'] = $errors; - } - - return $request; } /** @@ -229,11 +215,11 @@ private function buildInitiateCheckoutRequest(): array { $platformData = $this->platformInfoHelper->getMagentoDetails(); - $request = [ - 'channel' => 'Web', - 'platform' => 'Web', + return [ + 'channel' => self::CHANNEL_WEB, + 'platform' => self::PLATFORM_WEB, 'pluginVersion' => $this->platformInfoHelper->getModuleVersion(), - 'plugin' => 'adobeCommerce', + 'plugin' => self::PLUGIN_ADOBE_COMMERCE, 'applicationInfo' => [ 'merchantApplication' => [ 'name' => $this->platformInfoHelper->getModuleName(), @@ -246,22 +232,31 @@ private function buildInitiateCheckoutRequest(): array ] ] ]; - - return $request; } /** - * @param $response - * @return bool - * @throws InvalidArgumentException + * @throws ValidatorException */ - private function validateInitiateCheckoutAttemptResponse($response): bool + private function validateInitiateCheckoutAttemptResponse(array $response): void { if(!array_key_exists('checkoutAttemptId', $response)) { - throw new InvalidArgumentException(__('checkoutAttemptId is missing in the response!')); + throw new ValidatorException(__('checkoutAttemptId is missing in the response!')); } + } - return true; + /** + * @throws ValidatorException + */ + private function validateEventsAndContext(array $events, string $context): void + { + if (!in_array($context, array_keys(self::CONTEXT_MAX_ITEMS))) { + throw new ValidatorException(__('The analytics context %1 is invalid!', $context)); + } elseif (count($events) > self::CONTEXT_MAX_ITEMS[$context]) { + throw new ValidatorException(__( + 'There are too many events provided for %1 analytics context!', + $context + )); + } } /** @@ -287,6 +282,7 @@ private function getEndpointUrl(bool $isDemoMode): string * @param string $endpoint * @param array $payload * @return array|null + * @throws AdyenException */ private function sendRequest(string $endpoint, array $payload): ?array { @@ -294,55 +290,16 @@ private function sendRequest(string $endpoint, array $payload): ?array $this->curl->post($endpoint, json_encode($payload)); $result = $this->curl->getBody(); + $httpStatus = $this->curl->getStatus(); - if (empty($result)) { - return null; - } else { - return json_decode($result, true); - } - } - - // Add these helpers in the class + $hasFailed = !in_array($httpStatus, array(200, 201, 202, 204)); - /** - * Get a field from an event that may be an object (with getter/public prop) or array. - */ - private function getField($event, string $name) - { - $getter = 'get' . str_replace(' ', '', ucwords(str_replace(['_', '-'], ' ', $name))); - if (is_object($event)) { - if (method_exists($event, $getter)) { - return $event->{$getter}(); - } - if (isset($event->{$name})) { - return $event->{$name}; - } - } elseif (is_array($event)) { - return $event[$name] ?? null; + if ($hasFailed && $result) { + throw new AdyenException(__("Checkout Analytics API HTTP request failed (%1): %2", $httpStatus, $result)); + } elseif ($hasFailed && !$result) { + throw new AdyenException(__("Checkout Analytics API HTTP request failed with responseCode: %1)", $httpStatus)); } - return null; - } - /** - * Convert DateTimeInterface|string|int to milliseconds since epoch as a string (schema requires string). - */ - private function toUnixMillisString($value): string - { - if ($value instanceof \DateTimeInterface) { - return (string)($value->getTimestamp() * 1000); - } - if (is_int($value)) { - // if seconds (10 digits), convert; if already ms (13 digits), keep - return strlen((string)$value) >= 13 ? (string)$value : (string)($value * 1000); - } - // string: try to detect ms vs seconds vs date string - $trim = trim((string)$value); - if (ctype_digit($trim)) { - return strlen($trim) >= 13 ? $trim : (string)((int)$trim * 1000); - } - $ts = strtotime($trim); - return $ts !== false ? (string)($ts * 1000) : '0'; + return json_decode($result, true); } - - } diff --git a/Model/AnalyticsEvent.php b/Model/AnalyticsEvent.php index 899ec3130d..7fbf93415c 100644 --- a/Model/AnalyticsEvent.php +++ b/Model/AnalyticsEvent.php @@ -1,4 +1,14 @@ + */ + namespace Adyen\Payment\Model; use Adyen\Payment\Api\Data\AnalyticsEventInterface; @@ -62,6 +72,38 @@ public function setMessage(?string $message = null): AnalyticsEventInterface return $this->setData(self::MESSAGE, $message); } + public function getErrorType(): ?string + { + return $this->getData(self::ERROR_TYPE); + } + + /** + * This field refers to exception type related to the unexpected exception in case of `error` logging + * + * @param string|null $errorType + * @return AnalyticsEventInterface + */ + public function setErrorType(?string $errorType = null): AnalyticsEventInterface + { + return $this->setData(self::ERROR_TYPE, $errorType); + } + + public function getErrorCode(): ?string + { + return $this->getData(self::ERROR_CODE); + } + + /** + * This field refers to code related to the unexpected exception in case of `error` logging + * + * @param string|null $errorCode + * @return AnalyticsEventInterface + */ + public function setErrorCode(?string $errorCode = null): AnalyticsEventInterface + { + return $this->setData(self::ERROR_CODE, $errorCode); + } + public function getErrorCount(): int { return $this->getData(self::ERROR_COUNT); @@ -72,33 +114,49 @@ public function setErrorCount(int $errorCount): AnalyticsEventInterface return $this->setData(self::ERROR_COUNT, $errorCount); } - public function getStatus(): int + public function getStatus(): string { return $this->getData(self::STATUS); } - public function setStatus(int $status): AnalyticsEventInterface + public function setStatus(string $status): AnalyticsEventInterface { return $this->setData(self::STATUS, $status); } - public function getCreatedAt(): DateTime + public function getCreatedAt(): string { return $this->getData(self::CREATED_AT); } - public function setCreatedAt(DateTime $createdAt): AnalyticsEventInterface + public function setCreatedAt(string $createdAt): AnalyticsEventInterface { return $this->setData(self::CREATED_AT, $createdAt); } - public function getUpdatedAt(): ?DateTime + public function getCreatedAtTimestamp(): int + { + $dateTime = new DateTime($this->getCreatedAt()); + return $dateTime->getTimestamp(); + } + + public function getUpdatedAt(): ?string { return $this->getData(self::UPDATED_AT); } - public function setUpdatedAt(?DateTime $updatedAt = null): AnalyticsEventInterface + public function setUpdatedAt(?string $updatedAt = null): AnalyticsEventInterface { return $this->setData(self::UPDATED_AT, $updatedAt); } + + public function getScheduledProcessingTime(): ?string + { + return $this->getData(self::SCHEDULED_PROCESSING_TIME); + } + + public function setScheduledProcessingTime(?string $scheduledProcessingTime = null): AnalyticsEventInterface + { + return $this->setData(self::SCHEDULED_PROCESSING_TIME, $scheduledProcessingTime); + } } diff --git a/Model/AdyenAnalyticsRepository.php b/Model/AnalyticsEventRepository.php similarity index 92% rename from Model/AdyenAnalyticsRepository.php rename to Model/AnalyticsEventRepository.php index 0cada55e07..6f71d611d2 100644 --- a/Model/AdyenAnalyticsRepository.php +++ b/Model/AnalyticsEventRepository.php @@ -12,12 +12,12 @@ namespace Adyen\Payment\Model; -use Adyen\Payment\Api\AdyenAnalyticsRepositoryInterface; +use Adyen\Payment\Api\AnalyticsEventRepositoryInterface; use Adyen\Payment\Api\Data\AnalyticsEventInterface; use Adyen\Payment\Model\ResourceModel\AnalyticsEvent as AnalyticsEventResourceModel; use Magento\Framework\Exception\NoSuchEntityException; -class AdyenAnalyticsRepository implements AdyenAnalyticsRepositoryInterface +class AnalyticsEventRepository implements AnalyticsEventRepositoryInterface { public function __construct( protected readonly AnalyticsEventResourceModel $resourceModel, diff --git a/Model/ResourceModel/AnalyticsEvent/Collection.php b/Model/ResourceModel/AnalyticsEvent/Collection.php index 6eb7b69281..d239faec7f 100644 --- a/Model/ResourceModel/AnalyticsEvent/Collection.php +++ b/Model/ResourceModel/AnalyticsEvent/Collection.php @@ -12,6 +12,10 @@ namespace Adyen\Payment\Model\ResourceModel\AnalyticsEvent; +use Adyen\AdyenException; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; +use Adyen\Payment\Api\Data\AnalyticsEventStatusEnum; +use Adyen\Payment\Api\Data\AnalyticsEventTypeEnum; use Adyen\Payment\Model\AnalyticsEvent as AnalyticsEventModel; use Adyen\Payment\Model\ResourceModel\AnalyticsEvent as AnalyticsEventResourceModel; use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection; @@ -25,4 +29,42 @@ public function _construct() AnalyticsEventResourceModel::class ); } + + /** + * @param AnalyticsEventTypeEnum[] $analyticsEventTypes + * @return $this + * @throws AdyenException + */ + public function pendingAnalyticsEvents(array $analyticsEventTypes): Collection + { + if (!empty($analyticsEventTypes)) { + foreach ($analyticsEventTypes as $type) { + if ($type instanceof AnalyticsEventTypeEnum) { + $fields[] = AnalyticsEventInterface::TYPE; + $conditions[] = ['eq' => $type->value]; + } + } + + if (isset($conditions) && isset($fields)) { + $this->addFieldToFilter($fields, $conditions); + } else { + throw new AdyenException(__('Invalid analyticsEventTypes argument!')); + } + } else { + throw new AdyenException(__('Empty required analyticsEventTypes argument!')); + } + + $this->addFieldToFilter( + AnalyticsEventInterface::STATUS, + AnalyticsEventStatusEnum::PENDING->value + ); + + $this->addFieldToFilter(AnalyticsEventInterface::ERROR_COUNT, [ + 'lt' => AnalyticsEventInterface::MAX_ERROR_COUNT]); + + $this->addFieldToFilter(AnalyticsEventInterface::SCHEDULED_PROCESSING_TIME, [ + 'lt' => date('Y-m-d H:i:s')]); + + return $this; + } } diff --git a/Observer/ProcessAnalyticsEvent.php b/Observer/ProcessAnalyticsEvent.php index 78842c0ce7..099f394472 100644 --- a/Observer/ProcessAnalyticsEvent.php +++ b/Observer/ProcessAnalyticsEvent.php @@ -13,16 +13,16 @@ use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; -use Adyen\Payment\Api\AdyenAnalyticsRepositoryInterface; +use Adyen\Payment\Api\AnalyticsEventRepositoryInterface; use Adyen\Payment\Model\AnalyticsEventFactory; use Psr\Log\LoggerInterface; class ProcessAnalyticsEvent implements ObserverInterface { public function __construct( - protected readonly AdyenAnalyticsRepositoryInterface $adyenAnalyticsRepository, - protected readonly AnalyticsEventFactory $analyticsEventFactory, - protected readonly LoggerInterface $logger + protected readonly AnalyticsEventRepositoryInterface $adyenAnalyticsRepository, + protected readonly AnalyticsEventFactory $analyticsEventFactory, + protected readonly LoggerInterface $logger ) { } public function execute(Observer $observer) diff --git a/etc/crontab.xml b/etc/crontab.xml index 6e37957b15..8c45a0c3e8 100755 --- a/etc/crontab.xml +++ b/etc/crontab.xml @@ -27,5 +27,8 @@ */5 0 * * * + + */5 * * * * + diff --git a/etc/db_schema.xml b/etc/db_schema.xml index e131f2de5a..72c9b4f189 100644 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -134,10 +134,13 @@ + + + @@ -147,9 +150,17 @@ - + + + + + + + + + diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json index 35fec6c480..ed02c2746b 100644 --- a/etc/db_schema_whitelist.json +++ b/etc/db_schema_whitelist.json @@ -122,10 +122,13 @@ "type": true, "topic": true, "message": true, + "error_type": true, + "error_code": true, "error_count": true, "status": true, "created_at": true, - "updated_at": true + "updated_at": true, + "scheduled_processing_time": true }, "constraint": { "PRIMARY": true @@ -133,7 +136,7 @@ "index": { "ADYEN_ANALYTICS_EVENT_TYPE": true, "ADYEN_ANALYTICS_EVENT_STATUS": true, - "ADYEN_ANALYTICS_EVENT_TYPE_STATUS": true + "ADYEN_ANALYTICS_EVENT_TYPE_STATUS_ERROR_COUNT_SCHEDULED_PROCESSING_TIME": true } } } diff --git a/etc/di.xml b/etc/di.xml index 6887d5968e..d31c18f4ec 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -1699,7 +1699,7 @@ - + @@ -1746,6 +1746,18 @@ + + + + + Adyen\Payment\Cron\Providers\PendingInfoAnalyticsEventsProvider + + + Adyen\Payment\Cron\Providers\PendingErrorsAnalyticsEventsProvider + + + + @@ -4769,4 +4781,4 @@ adyen_riverty - \ No newline at end of file + From 6cb4399e9b4ee92e91b19bbe2e299c3593982fc1 Mon Sep 17 00:00:00 2001 From: Can Demiralp Date: Wed, 24 Sep 2025 14:17:42 +0200 Subject: [PATCH 2/4] [ECP-9751] Enable main workflow on the feature branch --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b6eb83256f..23887480d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,10 +3,10 @@ run-name: Main on: pull_request: - branches: [main] + branches: [main, feature-v10/reliability] # on-push trigger is required to analyse long-living branches on SonarCloud push: - branches: [main] + branches: [main, feature-v10/reliability] pull_request_target: branches: [main] From edd6ff799bccf630e7e273916add6a061fe04fd3 Mon Sep 17 00:00:00 2001 From: Can Demiralp Date: Fri, 3 Oct 2025 10:50:43 +0200 Subject: [PATCH 3/4] [ECP-9751] Write unit tests --- Helper/CheckoutAnalytics.php | 19 +- Test/Unit/Helper/CheckoutAnalyticsTest.php | 481 +++++++++++------- .../Resolver/StoreConfig/StoreLocaleTest.php | 6 +- 3 files changed, 316 insertions(+), 190 deletions(-) diff --git a/Helper/CheckoutAnalytics.php b/Helper/CheckoutAnalytics.php index fcb5adb232..e02552fcf4 100644 --- a/Helper/CheckoutAnalytics.php +++ b/Helper/CheckoutAnalytics.php @@ -23,9 +23,9 @@ class CheckoutAnalytics { const CHECKOUT_ANALYTICS_TEST_ENDPOINT = - 'https://checkoutanalytics-test.adyen.com//checkoutanalytics/v3/analytics'; + 'https://checkoutanalytics-test.adyen.com/checkoutanalytics/v3/analytics'; const CHECKOUT_ANALYTICS_LIVE_ENDPOINT = - 'https://checkoutanalytics.adyen.com//checkoutanalytics/v3/analytics'; + 'https://checkoutanalytics.adyen.com/checkoutanalytics/v3/analytics'; const CHECKOUT_ATTEMPT_ID = 'checkoutAttemptId'; const FLAVOR_COMPONENT = 'component'; const INTEGRATOR_ADYEN = 'Adyen'; @@ -94,8 +94,6 @@ public function sendAnalytics( string $context ): ?array { try { - $this->validateEventsAndContext($events, $context); - $request = $this->buildSendAnalyticsRequest($events, $context); $endpoint = $this->getSendAnalyticsUrl($checkoutAttemptId); @@ -143,11 +141,14 @@ private function getSendAnalyticsUrl(string $checkoutAttemptId): string * @param AnalyticsEventInterface[] $events * @param string $context Type of the analytics event [info, errors, logs] * @return array + * @throws ValidatorException */ private function buildSendAnalyticsRequest( array $events, string $context ): array { + $this->validateEventsAndContext($events, $context); + $items = []; foreach ($events as $event) { @@ -239,9 +240,13 @@ private function buildInitiateCheckoutRequest(): array */ private function validateInitiateCheckoutAttemptResponse(array $response): void { - if(!array_key_exists('checkoutAttemptId', $response)) { + if (!array_key_exists('checkoutAttemptId', $response)) { throw new ValidatorException(__('checkoutAttemptId is missing in the response!')); } + + if (empty($response['checkoutAttemptId'])) { + throw new ValidatorException(__('checkoutAttemptId is empty in the response!')); + } } /** @@ -294,9 +299,9 @@ private function sendRequest(string $endpoint, array $payload): ?array $hasFailed = !in_array($httpStatus, array(200, 201, 202, 204)); - if ($hasFailed && $result) { + if ($hasFailed && !empty($result)) { throw new AdyenException(__("Checkout Analytics API HTTP request failed (%1): %2", $httpStatus, $result)); - } elseif ($hasFailed && !$result) { + } elseif ($hasFailed && empty($result)) { throw new AdyenException(__("Checkout Analytics API HTTP request failed with responseCode: %1)", $httpStatus)); } diff --git a/Test/Unit/Helper/CheckoutAnalyticsTest.php b/Test/Unit/Helper/CheckoutAnalyticsTest.php index 163a0a8e65..48ed6abe24 100644 --- a/Test/Unit/Helper/CheckoutAnalyticsTest.php +++ b/Test/Unit/Helper/CheckoutAnalyticsTest.php @@ -3,40 +3,45 @@ namespace Adyen\Payment\Test\Unit\Helper; +use Adyen\AdyenException; +use Adyen\Payment\Api\Data\AnalyticsEventInterface; use Adyen\Payment\Helper\CheckoutAnalytics; use Adyen\Payment\Helper\Config; use Adyen\Payment\Helper\PlatformInfo; use Adyen\Payment\Logger\AdyenLogger; use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; use Magento\Framework\HTTP\ClientInterface; use Magento\Store\Api\Data\StoreInterface; use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; /** * PHPUnit 10-compliant tests for CheckoutAnalytics helper */ class CheckoutAnalyticsTest extends AbstractAdyenTestCase { - private Config $configHelperMock; - private PlatformInfo $platformInfoMock; - private StoreManagerInterface $storeManagerMock; - private AdyenLogger $loggerMock; - private ClientInterface $httpClient; - - private const STORE_ID = 1; - private const CLIENT_KEY = 'client_key_mock_XYZ1234567890'; - private const LIVE_URL = 'https://checkoutanalytics.adyen.com//checkoutanalytics/v3/analytics'; - private const TEST_URL = 'https://checkoutanalytics-test.adyen.com//checkoutanalytics/v3/analytics'; + protected CheckoutAnalytics $checkoutAnalytics; + protected Config|MockObject $configHelperMock; + protected PlatformInfo|MockObject $platformInfoMock; + protected StoreManagerInterface|MockObject $storeManagerMock; + protected AdyenLogger|MockObject $adyenLoggerMock; + protected ClientInterface|MockObject $httpClient; + + protected const STORE_ID = 1; + protected const CLIENT_KEY = 'client_key_mock_XYZ1234567890'; + protected const LIVE_URL = 'https://checkoutanalytics.adyen.com/checkoutanalytics/v3/analytics'; + protected const TEST_URL = 'https://checkoutanalytics-test.adyen.com/checkoutanalytics/v3/analytics'; protected function setUp(): void { parent::setUp(); - $this->configHelperMock = $this->createMock(Config::class); - $this->platformInfoMock = $this->createMock(PlatformInfo::class); - $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); - $this->loggerMock = $this->createMock(AdyenLogger::class); - $this->httpClient = $this->createMock(ClientInterface::class); + $this->configHelperMock = $this->createMock(Config::class); + $this->platformInfoMock = $this->createMock(PlatformInfo::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); + $this->httpClient = $this->createMock(ClientInterface::class); // Store $store = $this->createMock(StoreInterface::class); @@ -50,23 +55,62 @@ protected function setUp(): void ]); $this->platformInfoMock->method('getModuleName')->willReturn('Adyen_Payment'); $this->platformInfoMock->method('getModuleVersion')->willReturn('9.9.9'); - } - private function makeSut(): CheckoutAnalytics - { - return new CheckoutAnalytics( + $this->checkoutAnalytics = new CheckoutAnalytics( $this->configHelperMock, $this->platformInfoMock, $this->storeManagerMock, - $this->loggerMock, + $this->adyenLoggerMock, $this->httpClient ); } - public function testInitiateCheckoutAttempt_Live_SendsExpectedPayload_AndParsesResponse(): void + /** + * @return void + * @throws AdyenException + */ + public function testClientKeyNotSet(): void { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(self::CLIENT_KEY); + $this->expectException(AdyenException::class); + + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(true); + $this->configHelperMock->method('getClientKey') + ->with('test', self::STORE_ID) + ->willReturn(null); + + $this->checkoutAnalytics->initiateCheckoutAttempt(); + } + + /** + * @return array + */ + public static function initiateCheckoutAttemptDataProvider(): array + { + return [ + ['isDemoMode' => false], + ['isDemoMode' => true] + ]; + } + + /** + * @dataProvider initiateCheckoutAttemptDataProvider + * + * @param bool $isDemoMode + * @return void + * @throws AdyenException + */ + public function testInitiateCheckoutAttempt(bool $isDemoMode): void + { + $environment = $isDemoMode ? 'test' : 'live'; + + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn($isDemoMode); + $this->configHelperMock->method('getClientKey') + ->with($environment, self::STORE_ID) + ->willReturn(self::CLIENT_KEY); // Expect POST with the exact payload $expectedPayload = [ @@ -87,211 +131,284 @@ public function testInitiateCheckoutAttempt_Live_SendsExpectedPayload_AndParsesR ] ]; - $expectedUrl = sprintf('%s?clientKey=%s', self::LIVE_URL, self::CLIENT_KEY); + $endpoint = $isDemoMode ? self::TEST_URL : self::LIVE_URL; + $expectedUrl = sprintf('%s?clientKey=%s', $endpoint, self::CLIENT_KEY); - $this->httpClient->expects($this->once())->method('post') + $this->httpClient->expects($this->once()) + ->method('post') ->with($expectedUrl, json_encode($expectedPayload)); // Response body $this->httpClient->method('getBody')->willReturn('{"checkoutAttemptId":"abc123"}'); + $this->httpClient->method('getStatus')->willReturn(200); - $sut = $this->makeSut(); - $this->assertSame('abc123', $sut->initiateCheckoutAttempt()); + $this->assertSame('abc123', $this->checkoutAnalytics->initiateCheckoutAttempt()); } - public function testInitiateCheckoutAttempt_TestEnv_Works(): void + public static function failingHttpStatusDataProvider(): array { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(true); - $this->configHelperMock->method('getClientKey')->with('test', self::STORE_ID)->willReturn(self::CLIENT_KEY); + return [ + ['response' => 'Invalid request!'], + ['response' => ''] + ]; + } - $expectedUrl = sprintf('%s?clientKey=%s', self::TEST_URL, self::CLIENT_KEY); + /** + * @dataProvider failingHttpStatusDataProvider + * + * @param string $response + * @return void + * @throws AdyenException + */ + public function testFailingHttpStatus(string $response): void + { + $this->expectException(AdyenException::class); - // We don't assert payload again here; the previous test covers it. - $this->httpClient->expects($this->once())->method('post') - ->with($expectedUrl, $this->anything()); + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(true); + $this->configHelperMock->method('getClientKey') + ->with('test', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); - $this->httpClient->method('getBody')->willReturn('{"checkoutAttemptId":"test_env"}'); + $this->httpClient->method('getBody')->willReturn($response); + $this->httpClient->method('getStatus')->willReturn(400); - $sut = $this->makeSut(); - $this->assertSame('test_env', $sut->initiateCheckoutAttempt()); + $this->checkoutAnalytics->initiateCheckoutAttempt(); } - public function testInitiateCheckoutAttempt_IncorrectResponse_LogsError_AndReturnsNull(): void + /** + * @return void + * @throws AdyenException + */ + public function testInitiateCheckoutAttemptHandleException(): void { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(self::CLIENT_KEY); + $this->expectException(AdyenException::class); - $this->httpClient->method('getBody')->willReturn('{"someOtherKey":"x"}'); + $this->adyenLoggerMock->expects($this->once())->method('error'); + $this->platformInfoMock->method('getMagentoDetails')->willThrowException(new Exception()); - $this->loggerMock->expects($this->once())->method('error'); + $this->checkoutAnalytics->initiateCheckoutAttempt(); + } - $sut = $this->makeSut(); - $this->assertNull($sut->initiateCheckoutAttempt()); + /** + * @return array[] + */ + public static function validateInitiateCheckoutAttemptResponseDataProvider(): array + { + return [ + ['response' => '{"checkoutAttemptId":""}'], + ['response' => '{"result":"Success"}'] + ]; } - public function testInitiateCheckoutAttempt_MissingClientKey_LogsError_AndReturnsNull(): void + /** + * @dataProvider validateInitiateCheckoutAttemptResponseDataProvider + * + * @param string $response + * @return void + * @throws AdyenException + */ + public function testValidateInitiateCheckoutAttemptResponse(string $response): void { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(null); + $this->expectException(AdyenException::class); - $this->loggerMock->expects($this->once())->method('error'); + $this->httpClient->method('getBody')->willReturn($response); + $this->httpClient->method('getStatus')->willReturn(200); - $sut = $this->makeSut(); - $this->assertNull($sut->initiateCheckoutAttempt()); + $this->checkoutAnalytics->initiateCheckoutAttempt(); } - public function testSendAnalytics_BuildsPayloadWithCaps_AndPosts(): void + public static function validateEventsAndContextDataProvider(): array { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(self::CLIENT_KEY); + return [ + ['context' => 'errors'], + ['context' => 'logs'], + ['context' => 'info'] + ]; + } - $checkoutAttemptId = 'attempt_0123456789'; - $expectedUrl = sprintf('%s/%s?clientKey=%s', self::LIVE_URL, $checkoutAttemptId, self::CLIENT_KEY); + /** + * @dataProvider validateEventsAndContextDataProvider + * + * @param string $context + * @return void + */ + public function testValidateMaxNumberOfEvents(string $context): void + { + switch ($context) { + case CheckoutAnalytics::CONTEXT_TYPE_ERRORS: + $maxNumberOfEvents = CheckoutAnalytics::CONTEXT_MAX_ITEMS[CheckoutAnalytics::CONTEXT_TYPE_ERRORS]; + break; + case CheckoutAnalytics::CONTEXT_TYPE_INFO: + $maxNumberOfEvents = CheckoutAnalytics::CONTEXT_MAX_ITEMS[CheckoutAnalytics::CONTEXT_TYPE_INFO]; + break; + case CheckoutAnalytics::CONTEXT_TYPE_LOGS: + $maxNumberOfEvents = CheckoutAnalytics::CONTEXT_MAX_ITEMS[CheckoutAnalytics::CONTEXT_TYPE_LOGS]; + break; + } - // Build 55 info-capable events and 7 unexpectedEnd (errors-capable) - $baseCreatedAt = new \DateTimeImmutable('@1700000000'); // 1700000000 seconds -> 1700000000000 ms $events = []; - - // 55 informational (various types) – should cap to 50 in 'info' - $types = ['expectedStart', 'unexpectedStart', 'expectedEnd']; - for ($i = 0; $i < 55; $i++) { - $events[] = [ - 'createdAt' => $baseCreatedAt->modify("+{$i} seconds"), - 'uuid' => "uuid-info-{$i}", - 'topic' => "component-{$i}", - 'type' => $types[$i % count($types)], - 'relationId' => "rel-{$i}", - ]; + for ($i = 0; $i <= $maxNumberOfEvents; $i++) { + $events[] = 'MOCK_EVENT'; } - // 7 unexpectedEnd (should cap to 5 in 'errors') - for ($j = 0; $j < 7; $j++) { - $events[] = [ - 'createdAt' => $baseCreatedAt->modify("+{$j} minutes"), - 'uuid' => "uuid-err-{$j}", - 'topic' => "component-err-{$j}", - 'type' => 'unexpectedEnd', - 'relationId' => "rel-err-{$j}", - ]; - } + $checkoutAttemptId = 'XYZ123456789ABC'; - // One malformed event (missing relationId) -> must be skipped - $events[] = [ - 'createdAt' => $baseCreatedAt, - 'uuid' => 'uuid-bad', - 'topic' => 'component-bad', - 'type' => 'expectedStart', - // 'relationId' missing - ]; + $result = $this->checkoutAnalytics->sendAnalytics( + $checkoutAttemptId, + $events, + $context + ); - // We’ll capture the actual JSON body passed to the HTTP client to assert caps & mapping. - $this->httpClient->expects($this->once())->method('post') - ->with( - $expectedUrl, - $this->callback(function (string $json) use ($baseCreatedAt) { - $payload = json_decode($json, true); - - // Basic required fields - if (($payload['channel'] ?? null) !== 'Web') return false; - if (($payload['platform'] ?? null) !== 'Web') return false; - - // Caps - if (!isset($payload['info']) || count($payload['info']) !== 50) return false; - if (!isset($payload['errors']) || count($payload['errors']) !== 5) return false; - - // Spot-check first info item mapping - $first = $payload['info'][0]; - // createdAt base is 1700000000 -> ms string - if ($first['timestamp'] !== (string)(1700000000 * 1000)) return false; - if (!isset($first['type'])) return false; - if (!isset($first['target'])) return false; - if (!isset($first['id'])) return false; - if (!isset($first['component'])) return false; - - // Spot-check an errors item mapping (must have errorType Plugin, no type/target) - $err = $payload['errors'][0]; - if (($err['errorType'] ?? null) !== 'Plugin') return false; - if (isset($err['type']) || isset($err['target'])) return false; - - return true; - }) - ); - - // Response body irrelevant for send; just make it non-empty to avoid null - $this->httpClient->method('getBody')->willReturn('{"ok":true}'); - - $sut = $this->makeSut(); - $sut->sendAnalytics($checkoutAttemptId, $events); + $this->assertArrayHasKey('error', $result); } - public function testSendAnalytics_MissingClientKey_LogsError(): void + /** + * @return void + */ + public function testValidateInvalidContext(): void { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(null); - - $this->loggerMock->expects($this->once())->method('error'); - - $sut = $this->makeSut(); - $sut->sendAnalytics('attempt_X', [ - [ - 'createdAt' => new \DateTimeImmutable('@1700000000'), - 'uuid' => 'uuid-1', - 'topic' => 'component-1', - 'type' => 'expectedStart', - 'relationId' => 'rel-1', - ] - ]); - } + $events[] = 'MOCK_EVENT'; + $context = 'INVALID_CONTEXT'; - public function testSendAnalytics_EmptyEvents_LogsError(): void - { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); + $checkoutAttemptId = 'XYZ123456789ABC'; - $this->loggerMock->expects($this->once())->method('error'); + $result = $this->checkoutAnalytics->sendAnalytics( + $checkoutAttemptId, + $events, + $context + ); - $sut = $this->makeSut(); - $sut->sendAnalytics('attempt_X', []); // should trigger InvalidArgumentException and be logged + $this->assertArrayHasKey('error', $result); } - public function testSendAnalytics_SkipsMalformedEvents_ButStillSendsIfAnyValid(): void + public static function buildSendAnalyticsRequestDataProvider(): array { - $this->configHelperMock->method('isDemoMode')->with(self::STORE_ID)->willReturn(false); - $this->configHelperMock->method('getClientKey')->with('live', self::STORE_ID)->willReturn(self::CLIENT_KEY); - - $checkoutAttemptId = 'attempt_valid_partial'; - $expectedUrl = sprintf('%s/%s?clientKey=%s', self::LIVE_URL, $checkoutAttemptId, self::CLIENT_KEY); - - $events = [ - // malformed (missing uuid) - [ - 'createdAt' => new \DateTimeImmutable('@1700000000'), - 'topic' => 'component-x', - 'type' => 'expectedStart', - 'relationId' => 'rel-x', - ], - // valid - [ - 'createdAt' => new \DateTimeImmutable('@1700000001'), - 'uuid' => 'uuid-ok', - 'topic' => 'component-ok', - 'type' => 'expectedEnd', - 'relationId' => 'rel-ok', - ], + return [ + ['context' => 'errors'], + ['context' => 'logs'], + ['context' => 'info'] ]; + } + + /** + * @dataProvider buildSendAnalyticsRequestDataProvider + * + * @param string $context + * @return void + * @throws \PHPUnit\Framework\MockObject\Exception + */ + public function testBuildSendAnalyticsRequestInfoContext(string $context): void + { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(true); + $this->configHelperMock->method('getClientKey') + ->with('demo', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); + + $checkoutAttemptId = 'XYZ123456789ABC'; + + $events[] = $this->createConfiguredMock(AnalyticsEventInterface::class, [ + 'getCreatedAt' => '2025-01-01 01:00:00', + 'getTopic' => 'MOCK_TOPIC', + 'getUuid' => 'MOCK_UUID', + 'getType' => 'expectedStart', + 'getRelationId' => 'MOCK_TARGET', + 'getMessage' => 'MOCK_MESSAGE', + 'getErrorType' => 'MOCK_ERROR_TYPE', + 'getErrorCode' => 'MOCK_CODE', + ]); + + $this->checkoutAnalytics->sendAnalytics($checkoutAttemptId, $events, $context); + + switch ($context) { + case 'errors': + $payload = [ + 'channel' => 'Web', + 'platform' => 'Web', + 'errors' => [ + 'timestamp' => '2025-01-01 01:00:00', + 'component' => 'MOCK_TOPIC', + 'id' => 'MOCK_UUID', + 'message' => 'MOCK_MESSAGE', + 'errorType' => 'MOCK_ERROR_TYPE', + 'code' => 'MOCK_CODE' + ] + ]; + break; + case 'info': + $payload = [ + 'channel' => 'Web', + 'platform' => 'Web', + 'info' => [ + 'timestamp' => '2025-01-01 01:00:00', + 'component' => 'MOCK_TOPIC', + 'id' => 'MOCK_UUID', + 'type' => 'expectedStart', + 'target' => 'MOCK_TARGET' + ] + ]; + break; + case 'logs': + $payload = [ + 'channel' => 'Web', + 'platform' => 'Web', + 'logs' => [ + 'timestamp' => '2025-01-01 01:00:00', + 'component' => 'MOCK_TOPIC', + 'id' => 'MOCK_UUID', + 'type' => 'expectedStart', + 'message' => 'MOCK_MESSAGE' + ] + ]; + break; + } + + $expectedUrl = sprintf( + '%s/%s?clientKey=%s', + self::TEST_URL, + $checkoutAttemptId, + self::CLIENT_KEY + ); - $this->httpClient->expects($this->once())->method('post') - ->with( - $expectedUrl, - $this->callback(function (string $json) { - $payload = json_decode($json, true); - return isset($payload['info']) && count($payload['info']) === 1 - && !isset($payload['errors']); // expectedEnd -> not an error - }) - ); + $this->httpClient->method('post')->with($expectedUrl, $payload); + } + + /** + * @return void + */ + public function testClientKeyNotSetSendAnalyticsUrl(): void + { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(true); + $this->configHelperMock->method('getClientKey') + ->with('test', self::STORE_ID) + ->willReturn(null); + + $events[] = $this->createConfiguredMock(AnalyticsEventInterface::class, [ + 'getCreatedAt' => '2025-01-01 01:00:00', + 'getTopic' => 'MOCK_TOPIC', + 'getUuid' => 'MOCK_UUID', + 'getType' => 'expectedStart', + 'getRelationId' => 'MOCK_TARGET', + 'getMessage' => 'MOCK_MESSAGE', + 'getErrorType' => 'MOCK_ERROR_TYPE', + 'getErrorCode' => 'MOCK_CODE', + ]); - $this->httpClient->method('getBody')->willReturn('{"ok":true}'); + $result = $this->checkoutAnalytics->sendAnalytics( + 'XYZ123456789ABC', + $events, + 'info' + ); - $sut = $this->makeSut(); - $sut->sendAnalytics($checkoutAttemptId, $events); + $this->assertArrayHasKey('error', $result); } + + + + } diff --git a/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php b/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php index e1aa9a3178..1b37c8db6e 100644 --- a/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php +++ b/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php @@ -36,7 +36,11 @@ class StoreLocaleTest extends AbstractAdyenTestCase protected function setUp(): void { - $this->contextExtensionMock = $this->createMock(ContextExtensionInterface::class); + $this->contextExtensionMock = $this->createGeneratedMock( + ContextExtensionInterface::class, + [], + ['getStore'] + ); $this->contextMock = $this->getMockBuilder(Context::class) ->disableOriginalConstructor() From 64041b22df2be2d00edfdd30d23d1fcf1420ca2f Mon Sep 17 00:00:00 2001 From: Can Demiralp Date: Fri, 3 Oct 2025 11:29:39 +0200 Subject: [PATCH 4/4] [ECP-9751] Update tests --- Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php b/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php index 1b37c8db6e..e1aa9a3178 100644 --- a/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php +++ b/Test/Unit/Model/Resolver/StoreConfig/StoreLocaleTest.php @@ -36,11 +36,7 @@ class StoreLocaleTest extends AbstractAdyenTestCase protected function setUp(): void { - $this->contextExtensionMock = $this->createGeneratedMock( - ContextExtensionInterface::class, - [], - ['getStore'] - ); + $this->contextExtensionMock = $this->createMock(ContextExtensionInterface::class); $this->contextMock = $this->getMockBuilder(Context::class) ->disableOriginalConstructor()