diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..9267e7d --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,2 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ +_extends: .github diff --git a/.github/workflows/basic-linters.yml b/.github/workflows/basic-linters.yml new file mode 100644 index 0000000..6114f14 --- /dev/null +++ b/.github/workflows/basic-linters.yml @@ -0,0 +1,10 @@ +name: Vality basic linters + +on: + pull_request: + branches: + - "*" + +jobs: + lint: + uses: valitydev/base-workflows/.github/workflows/basic-linters.yml@v1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..67a681d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,10 @@ +name: Build Maven Artifact + +on: + pull_request: + branches: + - '*' + +jobs: + build: + uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..71e13b8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,13 @@ +name: Maven Deploy Artifact + +on: + push: + branches: + - 'master' + +jobs: + build-and-deploy: + uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v1 + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }} diff --git a/.github/workflows/settings.yml b/.github/workflows/settings.yml new file mode 100644 index 0000000..9267e7d --- /dev/null +++ b/.github/workflows/settings.yml @@ -0,0 +1,2 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ +_extends: .github diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0499eef --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Created by .ignore support plugin (hsz.mobi) +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin/*.beam +rel/example_project +.concrete/DEV_MODE +.rebar +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.DS_Store +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml + +# Sensitive or high-churn files: +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +*.iws +*.ipr +*.iml + + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +env.list diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index 6e76e46..5252d07 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ # beholder + +Сервис собирает метрики о производительности загрузки платёжной формы из разных регионов. + +## Особенности имплементации + +Точка входа в приложение: ```dev.vality.beholder.service.BeholderService.behold```. Этот метод вызывается по расписанию, указанном в свойстве ```schedule.cron```. + +### Подготовка данных для загрузки платёжной формы + +Для загрузки платёжной формы необходимы ```InvoiceId``` и ```InvoiceAccessToken```. + +Схема взаимодействия с [swag-payments](https://github.com/valitydev/swag-payments): + +![PaymentsImage](img/payments.drawio.svg) + +Алгоритм взаимодействия реализован здесь: ```dev.vality.beholder.service.PaymentsService.prepareFormData``` + +### Загрузка платёжной формы + +Beholder умеет работать c простым selenium-hub и с [lambdatest](https://www.lambdatest.com/). +В реальности обе интеграции работают через selenium API и являются совместимыми. + +Алгоритм загрузки и сбора метрик формы реализован здесь: ```dev.vality.beholder.service.SeleniumService.executePaymentRequest``` + +Его можно разбить на следующие шаги: + +1. Установить подключение с selenium-hub/lambdatest +2. Отправить запрос на загрузку формы +3. Собрать метрики загрузки формы посредством javascript'а (```dev.vality.beholder.util.SeleniumUtil.PERFORMANCE_SCRIPT```) +4. Заполнить форму и отправить запрос на проведение платежа +5. Собрать логи производительности браузера + +### Обновление метрик + +Собранная на предыдущем шаге информация о производительности формы записывается в соответствующие метрики prometheus'а. +Этот функционал реализован в классе ```dev.vality.beholder.service.MetricsService``` + +#### Метрики + +| Название | Лейблы | Описание | +|--------------------------------------------------|-----------------|------------------------------------------------------------------------------| +| beholder_form_loading_requests_total | browser, region | счетчик запросов на загрузку формы | +| beholder_form_loading_failed_total | browser, region | счетчик неудачных загрузок формы | +| beholder_form_dom_complete_duration_millis | browser, region | время от момента отправки запроса до полной загрузки формы в миллисекундах | +| beholder_form_waiting_response_duration_millis | browser, region | время от момента отправки запроса до начала получения ответа в миллисекундах | +| beholder_form_receiving_response_duration_millis | browser, region | время между получением первым и последним байтом информации в миллисекундах | +| beholder_form_resource_loading_duration_millis | browser, region | время, затраченное на загрузку ресурса (включая блокировки, ожидание и т.д) | + +## Тестирование + +Поскольку загружать во время юнит-тестирования реальную платежную форму не представляется возможным, +реализован интеграционный тест, который отключен по умолчанию, однако может использоваться для локальной отладки сервиса. + +Тест: ```dev.vality.beholder.IntegrationTest``` +Подготовка к запуску теста: + +1. Прописать валидные значения в следующих свойствах: + 1. payments.api-url - адрес для обращения к api + 2. payments.form-url - адрес для загрузки платёжной формы + 3. payments.request.shop-id - идентификатор магазина, который нужно использовать +2. Прописать в свойстве ```dev.vality.beholder.IntegrationTest.TEST_USER_TOKEN``` валидный токен +3. Готово, можно запускать тест. + +## Полезные ссылки + +[Описание метрик производительности](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings), которые можно получить через JS. + +[Описание метрик сети](https://chromedevtools.github.io/devtools-protocol/tot/Network/), которые можно получить от chromium'а. + +[Регионы](https://www.lambdatest.com/capabilities-generator/), доступные для тестирования. На их основе заполнен справочник ```regions.json``` diff --git a/img/payments.drawio.svg b/img/payments.drawio.svg new file mode 100644 index 0000000..292b76c --- /dev/null +++ b/img/payments.drawio.svg @@ -0,0 +1,9 @@ +BeholderValitygetToken()200 (UserToken)getUserParty(UserToken)200 (Party)getShopByIdForParty(Party)404 (ShopNotFoundError)createClaim()Keycloak200(ShopCreated)createInvoice(shopId)200(InvoiceAcceccToken + InvoiceId)
alt
alt
[shop not found]
[shop not fou...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a08e719 --- /dev/null +++ b/pom.xml @@ -0,0 +1,204 @@ + + + 4.0.0 + + + dev.vality + service-parent-pom + 1.0.16 + + + beholder + 0.0.1-SNAPSHOT + jar + + beholder + + + UTF-8 + UTF-8 + 15 + 8022 + 8023 + ${server.port} ${management.port} + + + + + + + + dev.vality.woody + woody-thrift + + + dev.vality + shared-resources + + + dev.vality + swag-payments + 1.627-0089567-client + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.hibernate + hibernate-validator + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-validation + + + + + javax.servlet + javax.servlet-api + + + org.projectlombok + lombok + provided + + + io.micrometer + micrometer-core + + + io.micrometer + micrometer-registry-prometheus + + + org.seleniumhq.selenium + selenium-java + + + org.keycloak + keycloak-admin-client + 15.1.1 + + + org.jboss.resteasy + resteasy-client + + + org.jboss.resteasy + resteasy-multipart-provider + + + org.jboss.resteasy + resteasy-jackson2-provider + + + org.jboss.resteasy + resteasy-jaxb-provider + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers + 1.17.2 + test + + + org.testcontainers + junit-jupiter + 1.17.2 + test + + + com.github.oshi + oshi-core-java11 + 6.1.6 + test + + + + + + ${project.basedir}/src/main/java + ${project.basedir}/src/test/java + + + ${project.build.directory}/maven-shared-archive-resources + ${project.build.directory} + + Dockerfile + + true + + + ${project.build.directory}/maven-shared-archive-resources + true + + Dockerfile + + + + src/main/resources + true + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-remote-resources-plugin + 1.7.0 + + + org.apache.maven.shared + maven-filtering + 3.2.0 + + + + + dev.vality:shared-resources:${shared-resources.version} + + false + false + + + + + process + + + + + + + diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a20bfd6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["local>valitydev/.github:renovate-config"] +} diff --git a/src/main/java/dev/vality/beholder/BeholderApplication.java b/src/main/java/dev/vality/beholder/BeholderApplication.java new file mode 100644 index 0000000..cfa0258 --- /dev/null +++ b/src/main/java/dev/vality/beholder/BeholderApplication.java @@ -0,0 +1,17 @@ +package dev.vality.beholder; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@ServletComponentScan +@SpringBootApplication +@EnableScheduling +public class BeholderApplication extends SpringApplication { + + public static void main(String[] args) { + SpringApplication.run(BeholderApplication.class, args); + } + +} diff --git a/src/main/java/dev/vality/beholder/config/PaymentsConfig.java b/src/main/java/dev/vality/beholder/config/PaymentsConfig.java new file mode 100644 index 0000000..49822c6 --- /dev/null +++ b/src/main/java/dev/vality/beholder/config/PaymentsConfig.java @@ -0,0 +1,73 @@ +package dev.vality.beholder.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import dev.vality.beholder.config.properties.PaymentsProperties; +import dev.vality.swag.payments.ApiClient; +import dev.vality.swag.payments.api.ClaimsApi; +import dev.vality.swag.payments.api.InvoicesApi; +import dev.vality.swag.payments.api.PartiesApi; +import dev.vality.swag.payments.api.ShopsApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.text.SimpleDateFormat; + +@Configuration +public class PaymentsConfig { + + private static final String BEARER_TYPE = "Bearer"; + private static final String DATE_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + + @Bean + public ClientHttpRequestFactory clientHttpRequestFactory(PaymentsProperties paymentsProperties) { + var requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(paymentsProperties.getApiTimeoutSec().intValue() * 1000); + return requestFactory; + } + + @Bean + public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) { + var restTemplate = new RestTemplate(clientHttpRequestFactory); + restTemplate.getMessageConverters() + .removeIf(m -> m.getClass().getName().equals(MappingJackson2HttpMessageConverter.class.getName())); + Jackson2ObjectMapperBuilder builder = + new Jackson2ObjectMapperBuilder() + .serializationInclusion(JsonInclude.Include.NON_NULL) + .dateFormat(new SimpleDateFormat(DATE_TIME_PATTERN)); + restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter(builder.build())); + return restTemplate; + } + + @Bean + public ApiClient apiClient(RestTemplate restTemplate, PaymentsProperties paymentsProperties) { + ApiClient apiClient = new ApiClient(restTemplate); + apiClient.setBasePath(paymentsProperties.getApiUrl()); + apiClient.setApiKeyPrefix(BEARER_TYPE); + return apiClient; + } + + @Bean + public PartiesApi partiesApi(ApiClient apiClient) { + return new PartiesApi(apiClient); + } + + @Bean + public ClaimsApi claimsApi(ApiClient apiClient) { + return new ClaimsApi(apiClient); + } + + @Bean + public ShopsApi shopsApi(ApiClient apiClient) { + return new ShopsApi(apiClient); + } + + @Bean + public InvoicesApi invoicesApi(ApiClient apiClient) { + return new InvoicesApi(apiClient); + } +} diff --git a/src/main/java/dev/vality/beholder/config/SeleniumConfig.java b/src/main/java/dev/vality/beholder/config/SeleniumConfig.java new file mode 100644 index 0000000..6932bb0 --- /dev/null +++ b/src/main/java/dev/vality/beholder/config/SeleniumConfig.java @@ -0,0 +1,68 @@ +package dev.vality.beholder.config; + +import dev.vality.beholder.config.properties.PaymentsProperties; +import dev.vality.beholder.config.properties.SeleniumProperties; +import dev.vality.beholder.converter.LogEntriesToNetworkLogsConverter; +import dev.vality.beholder.model.Region; +import dev.vality.beholder.service.SeleniumService; +import dev.vality.beholder.util.FileUtil; +import dev.vality.beholder.util.SeleniumUtil; +import org.openqa.selenium.remote.DesiredCapabilities; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.stream.Collectors; + +@Configuration +public class SeleniumConfig { + + @Value("${dictionary.regions}") + private Resource dictionaryRegions; + + @Bean(name = "remoteFormService") + @ConditionalOnProperty(name = "selenium.use-external-provider", havingValue = "true") + public SeleniumService lambdaTestRemoteFormService(SeleniumProperties seleniumProperties, + PaymentsProperties paymentsProperties, + LogEntriesToNetworkLogsConverter converter) + throws MalformedURLException { + URL lambdaTestUrl = buildLambdaTestUrl(seleniumProperties.getLambdaTest()); + String formUrl = paymentsProperties.getFormUrl(); + Long formTimeoutSec = paymentsProperties.getFormTimeoutSec(); + DesiredCapabilities desiredCapabilities = SeleniumUtil.getLambdaTestCapabilities(); + return new SeleniumService(lambdaTestUrl, formUrl, formTimeoutSec, desiredCapabilities, converter); + } + + @Bean(name = "remoteFormService") + @ConditionalOnProperty(name = "selenium.use-external-provider", havingValue = "false") + public SeleniumService seleniumRemoteFormService(SeleniumProperties seleniumProperties, + PaymentsProperties paymentsProperties, + LogEntriesToNetworkLogsConverter converter) + throws MalformedURLException { + URL seleniumUrl = new URL(seleniumProperties.getUrl() + ":" + seleniumProperties.getPort()); + String formUrl = paymentsProperties.getFormUrl(); + Long formTimeoutSec = paymentsProperties.getFormTimeoutSec(); + DesiredCapabilities desiredCapabilities = SeleniumUtil.getCommonCapabilities(); + return new SeleniumService(seleniumUrl, formUrl, formTimeoutSec, desiredCapabilities, converter); + } + + @Bean + public List regions(SeleniumProperties seleniumProperties) throws IOException { + return FileUtil.readRegions(dictionaryRegions) + .stream().filter(region -> seleniumProperties.getRegions().contains(region.getCode())) + .collect(Collectors.toList()); + } + + private URL buildLambdaTestUrl(SeleniumProperties.LambdaTestProperties lambdaTestProperties) + throws MalformedURLException { + String url = "https://" + lambdaTestProperties.getUser() + ":" + lambdaTestProperties.getToken() + + "@hub.lambdatest.com/wd/hub"; + return new URL(url); + } +} diff --git a/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java b/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java new file mode 100644 index 0000000..5b0b872 --- /dev/null +++ b/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java @@ -0,0 +1,30 @@ +package dev.vality.beholder.config.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; + +@Getter +@Setter +@Component +@Validated +@ConfigurationProperties(prefix = "keycloak") +public class KeycloakProperties { + + @NotEmpty + private String user; + + @NotEmpty + private String password; + + @NotEmpty + private String url; + + @NotEmpty + private String resource; + +} diff --git a/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java b/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java new file mode 100644 index 0000000..515e551 --- /dev/null +++ b/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java @@ -0,0 +1,49 @@ +package dev.vality.beholder.config.properties; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +@Getter +@Setter +@Component +@Validated +@ConfigurationProperties(prefix = "payments") +public class PaymentsProperties { + + @NotEmpty + private String apiUrl; + + private Long apiTimeoutSec = 10L; + + @NotEmpty + private String formUrl; + + private Long formTimeoutSec = 30L; + + @NotNull + private Request request; + + @Data + public static class Request { + @NotNull + private String shopId; + private Boolean createShopIfNotFound = false; + private Integer paymentInstitutionId; + private Integer categoryId; + } + + @AssertTrue(message = "Check 'create-shop-if-not-found' option and related parameters") + private boolean isRequestConfigurationValid() { + return !request.createShopIfNotFound + || (request.getPaymentInstitutionId() != null && request.getCategoryId() != null); + } + +} diff --git a/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java b/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java new file mode 100644 index 0000000..e8af7f3 --- /dev/null +++ b/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java @@ -0,0 +1,49 @@ +package dev.vality.beholder.config.properties; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Getter +@Setter +@Component +@Validated +@ConfigurationProperties(prefix = "selenium") +public class SeleniumProperties { + + private String url; + + private Integer port; + + @NotNull + private Boolean useExternalProvider; + + @NotEmpty + private List regions; + + private LambdaTestProperties lambdaTest; + + @Data + public static class LambdaTestProperties { + + private String user; + + private String token; + } + + @AssertTrue(message = "Check 'use-external-provider' option and related parameters") + private boolean isSeleniumConfigurationValid() { + return useExternalProvider && lambdaTest != null && !ObjectUtils.isEmpty(lambdaTest.token) + && !ObjectUtils.isEmpty(lambdaTest.user) + || !useExternalProvider && !ObjectUtils.isEmpty(url); + } +} diff --git a/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java b/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java new file mode 100644 index 0000000..ab2e3be --- /dev/null +++ b/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java @@ -0,0 +1,46 @@ +package dev.vality.beholder.converter; + +import dev.vality.beholder.exception.BadFormatException; +import dev.vality.beholder.model.NetworkLog; +import dev.vality.beholder.model.NetworkMethod; +import org.openqa.selenium.logging.LogEntry; +import org.springframework.boot.configurationprocessor.json.JSONException; +import org.springframework.boot.configurationprocessor.json.JSONObject; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +public class LogEntriesToNetworkLogsConverter implements Converter, List> { + + @Override + public List convert(List source) { + Map networkLogs = new HashMap<>(); + for (LogEntry logEntry : source) { + try { + //Message format doc: https://chromedevtools.github.io/devtools-protocol/tot/Network/ + JSONObject log = new JSONObject(logEntry.getMessage()); + JSONObject message = log.getJSONObject("message"); + JSONObject params = message.getJSONObject("params"); + if (params.has("requestId")) { + String requestId = params.getString("requestId"); + String method = message.getString("method"); + if (NetworkMethod.REQUEST_WILL_BE_SENT.getValue().equals(method)) { + double time = params.getDouble("timestamp") * 1000; + String resource = params.getJSONObject("request").getString("url"); + networkLogs.put(requestId, new NetworkLog(resource, time, null)); + } else if (NetworkMethod.LOADING_FINISHED.getValue().equals(method)) { + double time = params.getDouble("timestamp") * 1000; + NetworkLog networkLog = + networkLogs.getOrDefault(requestId, new NetworkLog(requestId, null, null)); + networkLog.setEnd(time); + } + } + } catch (JSONException e) { + throw new BadFormatException("Error during parsing network logs:", e); + } + } + return new ArrayList<>(networkLogs.values()); + } +} diff --git a/src/main/java/dev/vality/beholder/exception/BadFormatException.java b/src/main/java/dev/vality/beholder/exception/BadFormatException.java new file mode 100644 index 0000000..a008bc6 --- /dev/null +++ b/src/main/java/dev/vality/beholder/exception/BadFormatException.java @@ -0,0 +1,16 @@ +package dev.vality.beholder.exception; + +public class BadFormatException extends RuntimeException { + + public BadFormatException(String message) { + super(message); + } + + public BadFormatException(Throwable throwable) { + super(throwable); + } + + public BadFormatException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/dev/vality/beholder/model/Browser.java b/src/main/java/dev/vality/beholder/model/Browser.java new file mode 100644 index 0000000..4d6708c --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/Browser.java @@ -0,0 +1,13 @@ +package dev.vality.beholder.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum Browser { + CHROME("chrome"); + + @Getter + private final String label; +} diff --git a/src/main/java/dev/vality/beholder/model/FormDataRequest.java b/src/main/java/dev/vality/beholder/model/FormDataRequest.java new file mode 100644 index 0000000..41e211a --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/FormDataRequest.java @@ -0,0 +1,26 @@ +package dev.vality.beholder.model; + +import lombok.Builder; +import lombok.Data; +import lombok.ToString; + +@Data +public class FormDataRequest { + + private String invoiceId; + + private String invoiceAccessToken; + + private Card cardInfo; + + @Data + @Builder + public static class Card { + @ToString.Exclude + private String pan; + private String expiration; + @ToString.Exclude + private String cvv; + } + +} diff --git a/src/main/java/dev/vality/beholder/model/FormDataResponse.java b/src/main/java/dev/vality/beholder/model/FormDataResponse.java new file mode 100644 index 0000000..7b998aa --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/FormDataResponse.java @@ -0,0 +1,38 @@ +package dev.vality.beholder.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class FormDataResponse { + + private FormDataRequest request; + + private FormPerformance formPerformance; + + private Browser browser; + + private Region region; + + private List networkLogs; + + private boolean failed; + + @Data + @Builder + public static class FormPerformance { + + private Double requestStartAt; + + private Double responseStartAt; + + private Double responseEndAt; + + private Double domCompletedAt; + + } + +} diff --git a/src/main/java/dev/vality/beholder/model/NetworkLog.java b/src/main/java/dev/vality/beholder/model/NetworkLog.java new file mode 100644 index 0000000..dbb825f --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/NetworkLog.java @@ -0,0 +1,16 @@ +package dev.vality.beholder.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class NetworkLog { + + private String resource; + + private Double start; + + private Double end; + +} diff --git a/src/main/java/dev/vality/beholder/model/NetworkMethod.java b/src/main/java/dev/vality/beholder/model/NetworkMethod.java new file mode 100644 index 0000000..85f6357 --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/NetworkMethod.java @@ -0,0 +1,17 @@ +package dev.vality.beholder.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum NetworkMethod { + + REQUEST_WILL_BE_SENT("Network.requestWillBeSent"), + + RESPONSE_RECEIVED("Network.responseReceived"), + LOADING_FINISHED("Network.loadingFinished"); + + @Getter + private final String value; + +} diff --git a/src/main/java/dev/vality/beholder/model/Region.java b/src/main/java/dev/vality/beholder/model/Region.java new file mode 100644 index 0000000..1cdf027 --- /dev/null +++ b/src/main/java/dev/vality/beholder/model/Region.java @@ -0,0 +1,16 @@ +package dev.vality.beholder.model; + +import lombok.Data; + +@Data +public class Region { + + private String code; + + private String country; + + @Override + public String toString() { + return country + "[" + code + "]"; + } +} diff --git a/src/main/java/dev/vality/beholder/security/KeycloakService.java b/src/main/java/dev/vality/beholder/security/KeycloakService.java new file mode 100644 index 0000000..9c4c751 --- /dev/null +++ b/src/main/java/dev/vality/beholder/security/KeycloakService.java @@ -0,0 +1,30 @@ +package dev.vality.beholder.security; + +import dev.vality.beholder.config.properties.KeycloakProperties; +import lombok.RequiredArgsConstructor; +import org.keycloak.OAuth2Constants; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class KeycloakService { + + private final RestTemplate restTemplate; + private final KeycloakProperties keycloakProperties; + + public String getUserToken() { + HttpEntity> request = + new TokenRequest.Builder(keycloakProperties.getResource(), OAuth2Constants.PASSWORD) + .add("username", keycloakProperties.getUser()) + .add("password", keycloakProperties.getPassword()) + .build(); + ResponseEntity response = + restTemplate.postForEntity(keycloakProperties.getUrl(), request, String.class); + return response.getBody(); + } + +} diff --git a/src/main/java/dev/vality/beholder/security/TokenRequest.java b/src/main/java/dev/vality/beholder/security/TokenRequest.java new file mode 100644 index 0000000..60123e4 --- /dev/null +++ b/src/main/java/dev/vality/beholder/security/TokenRequest.java @@ -0,0 +1,36 @@ +package dev.vality.beholder.security; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.Collections; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TokenRequest { + public static class Builder { + MultiValueMap data; + + public Builder(String clientId, String grantType) { + data = new LinkedMultiValueMap<>(); + data.put("client_id", Collections.singletonList(clientId)); + data.put("grant_type", Collections.singletonList(grantType)); + } + + public Builder add(String key, String value) { + data.put(key, Collections.singletonList(value)); + return this; + } + + public HttpEntity> build() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + return new HttpEntity<>(data, headers); + } + } + +} diff --git a/src/main/java/dev/vality/beholder/service/BeholderService.java b/src/main/java/dev/vality/beholder/service/BeholderService.java new file mode 100644 index 0000000..697996a --- /dev/null +++ b/src/main/java/dev/vality/beholder/service/BeholderService.java @@ -0,0 +1,47 @@ +package dev.vality.beholder.service; + +import dev.vality.beholder.model.FormDataRequest; +import dev.vality.beholder.model.FormDataResponse; +import dev.vality.beholder.model.Region; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BeholderService { + + private final PaymentsService paymentsService; + private final SeleniumService seleniumService; + private final MetricsService metricsService; + + private final List regions; + + @Scheduled(cron = "${schedule.cron:-}") + public void behold() { + log.info("Start sending requests from {} regions", regions); + List responses = new ArrayList<>(); + for (Region region : regions) { + FormDataRequest request; + try { + log.debug("Preparing request for {} region", region.getCountry()); + request = paymentsService.prepareFormData(); + log.debug("Request for {} region successfully prepared", region.getCountry()); + } catch (Exception e) { + log.error("Unable to prepare request for {}:", region.getCountry(), e); + continue; + } + FormDataResponse response = seleniumService.executePaymentRequest(request, region); + responses.add(response); + log.debug("Metrics for {} region saved", region.getCountry()); + } + metricsService.updateMetrics(responses); + log.info("Finished processing requests from {} regions", regions); + + } +} diff --git a/src/main/java/dev/vality/beholder/service/MetricsService.java b/src/main/java/dev/vality/beholder/service/MetricsService.java new file mode 100644 index 0000000..01a3f1a --- /dev/null +++ b/src/main/java/dev/vality/beholder/service/MetricsService.java @@ -0,0 +1,158 @@ +package dev.vality.beholder.service; + +import dev.vality.beholder.model.FormDataResponse; +import dev.vality.beholder.util.MetricUtil; +import io.micrometer.core.instrument.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import static java.util.stream.Collectors.toList; + +@Service +@Slf4j +public class MetricsService { + + private final MeterRegistry meterRegistry; + private final MultiGauge resourcesLoadingTimings; + private final MultiGauge formDataWaitingDurationGauges; + private final MultiGauge formDataReceivingDuration; + private final MultiGauge formDomCompleteDuration; + private final Map formLoadingCounters; + private final Map formLoadingFailedCounters; + + + public MetricsService(MeterRegistry meterRegistry) { + + this.meterRegistry = meterRegistry; + + this.resourcesLoadingTimings = MultiGauge.builder("beholder_form_resource_loading_duration") + .description("Resources uploading time") + .baseUnit("millis") + .register(meterRegistry); + + this.formDataWaitingDurationGauges = MultiGauge.builder("beholder_form_waiting_response_duration") + .description("Time between sending request and first received byte of data") + .baseUnit("millis") + .register(meterRegistry); + + this.formDataReceivingDuration = MultiGauge.builder("beholder_form_receiving_response_duration") + .description("Time between receiving first and last byte of data") + .baseUnit("millis") + .register(meterRegistry); + + this.formDomCompleteDuration = MultiGauge.builder("beholder_form_dom_complete_duration") + .description("Time between sending request and fully rendered DOM") + .baseUnit("millis") + .register(meterRegistry); + + this.formLoadingCounters = new HashMap<>(); + + this.formLoadingFailedCounters = new HashMap<>(); + + } + + public void updateMetrics(List formDataResponses) { + log.debug("Updating beholder metrics started"); + updateWaitingResponseDuration(formDataResponses); + updateFormDataReceivingDuration(formDataResponses); + updateFormDomCompleteDuration(formDataResponses); + updateResourceLoadingDuration(formDataResponses); + updateFormLoadingRequestsTotal(formDataResponses); + updateFormLoadingFailedRequestsTotal(formDataResponses); + log.debug("Updating beholder metrics finished"); + } + + private void updateWaitingResponseDuration(List formDataResponses) { + formDataWaitingDurationGauges.register( + formDataResponses.stream() + .filter(Predicate.not(FormDataResponse::isFailed)) + .map(formDataResponse -> MultiGauge.Row.of( + MetricUtil.createCommonTags(formDataResponse), + MetricUtil.calculateWaitingResponseDuration(formDataResponse.getFormPerformance()))) + .collect(toList()), + true + ); + } + + private void updateFormDataReceivingDuration(List formDataResponses) { + formDataReceivingDuration.register( + formDataResponses.stream() + .filter(Predicate.not(FormDataResponse::isFailed)) + .map(formDataResponse -> MultiGauge.Row.of( + MetricUtil.createCommonTags(formDataResponse), + MetricUtil.calculateDataReceivingDuration(formDataResponse.getFormPerformance()))) + .collect(toList()), + true + ); + } + + private void updateFormDomCompleteDuration(List formDataResponses) { + formDomCompleteDuration.register( + formDataResponses.stream() + .filter(Predicate.not(FormDataResponse::isFailed)) + .map(formDataResponse -> MultiGauge.Row.of( + MetricUtil.createCommonTags(formDataResponse), + MetricUtil.calculateDomCompleteDuration(formDataResponse.getFormPerformance()))) + .collect(toList()), + true + ); + } + + private void updateFormLoadingRequestsTotal(List formDataResponses) { + for (FormDataResponse response : formDataResponses) { + String id = MetricUtil.getCounterId(response); + Counter counter = formLoadingCounters.getOrDefault(id, + Counter.builder("beholder_form_loading_requests") + .description("Total requests for form upload") + .tags(MetricUtil.createCommonTags(response)) + .baseUnit("total") + .register(meterRegistry)); + counter.increment(); + formLoadingCounters.put(id, counter); + } + } + + private void updateFormLoadingFailedRequestsTotal(List formDataResponses) { + for (FormDataResponse response : formDataResponses) { + if (response.isFailed()) { + String id = MetricUtil.getCounterId(response); + Counter counter = formLoadingFailedCounters.getOrDefault(id, + Counter.builder("beholder_form_loading_failed") + .description("Total failed requests for form upload") + .tags(MetricUtil.createCommonTags(response)) + .baseUnit("total") + .register(meterRegistry)); + counter.increment(); + formLoadingFailedCounters.put(id, counter); + } + } + } + + private void updateResourceLoadingDuration(List formDataResponses) { + formDataResponses.stream() + .filter(Predicate.not(FormDataResponse::isFailed)) + .forEach( + formDataResponse -> + resourcesLoadingTimings.register( + formDataResponse.getNetworkLogs().stream() + .map(networkLog -> MultiGauge.Row.of( + MetricUtil.createCommonTags(formDataResponse) + .and("resource", + MetricUtil.getNormalisedPath(networkLog, + formDataResponse.getRequest() + .getInvoiceId(), + formDataResponse.getRequest() + .getInvoiceAccessToken())), + MetricUtil.calculateRequestDuration(networkLog))) + .collect(toList()), + true + ) + ); + } + +} diff --git a/src/main/java/dev/vality/beholder/service/PaymentsService.java b/src/main/java/dev/vality/beholder/service/PaymentsService.java new file mode 100644 index 0000000..927782e --- /dev/null +++ b/src/main/java/dev/vality/beholder/service/PaymentsService.java @@ -0,0 +1,77 @@ +package dev.vality.beholder.service; + +import dev.vality.beholder.config.properties.PaymentsProperties; +import dev.vality.beholder.model.FormDataRequest; +import dev.vality.beholder.security.KeycloakService; +import dev.vality.beholder.util.PaymentsUtil; +import dev.vality.swag.payments.ApiClient; +import dev.vality.swag.payments.api.ClaimsApi; +import dev.vality.swag.payments.api.InvoicesApi; +import dev.vality.swag.payments.api.PartiesApi; +import dev.vality.swag.payments.api.ShopsApi; +import dev.vality.swag.payments.model.*; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; + +@Service +@RequiredArgsConstructor +public class PaymentsService { + + private final ApiClient apiClient; + private final PartiesApi partiesApi; + private final ShopsApi shopsApi; + private final InvoicesApi invoicesApi; + private final ClaimsApi claimsApi; + private final KeycloakService keycloakService; + private final PaymentsProperties paymentsProperties; + + public FormDataRequest prepareFormData() { + apiClient.setApiKey(keycloakService.getUserToken()); + String shopId = getShopId(); + InvoiceParams invoiceParams = PaymentsUtil.createInvoiceParams(shopId); + InvoiceAndToken invoiceAndToken = invoicesApi.createInvoice(PaymentsUtil.getRequestId(), invoiceParams, + PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec())); + return createFormDataRequest(invoiceAndToken); + } + + private String getShopId() { + Party party = partiesApi.getMyParty(PaymentsUtil.getRequestId(), + PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec())); + var request = paymentsProperties.getRequest(); + String shopId = request.getShopId(); + try { + shopsApi.getShopByIDForParty(PaymentsUtil.getRequestId(), shopId, party.getId(), + PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec())); + } catch (HttpStatusCodeException httpStatusCodeException) { + if (!isNotFoundError(httpStatusCodeException) || !request.getCreateShopIfNotFound()) { + throw httpStatusCodeException; + } + sendShopCreationClaim(request, shopId); + } + return shopId; + } + + private void sendShopCreationClaim(PaymentsProperties.Request request, String shopId) { + ClaimChangeset changeset = PaymentsUtil.buildCreateShopClaim(request.getPaymentInstitutionId(), + shopId, request.getCategoryId()); + claimsApi.createClaim(PaymentsUtil.getRequestId(), changeset, + PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec())); + } + + private FormDataRequest createFormDataRequest(InvoiceAndToken invoiceAndToken) { + FormDataRequest formDataRequest = new FormDataRequest(); + formDataRequest.setCardInfo(FormDataRequest.Card.builder() + .pan(PaymentsUtil.TEST_CARD_PAN) + .cvv(PaymentsUtil.TEST_CARD_CVV) + .expiration(PaymentsUtil.TEST_CARD_EXPIRATION).build()); + formDataRequest.setInvoiceId(invoiceAndToken.getInvoice().getId()); + formDataRequest.setInvoiceAccessToken(invoiceAndToken.getInvoiceAccessToken().getPayload()); + return formDataRequest; + } + + private boolean isNotFoundError(HttpStatusCodeException httpStatusCodeException) { + return HttpStatus.NOT_FOUND.equals(httpStatusCodeException.getStatusCode()); + } +} diff --git a/src/main/java/dev/vality/beholder/service/SeleniumService.java b/src/main/java/dev/vality/beholder/service/SeleniumService.java new file mode 100644 index 0000000..0e5cc37 --- /dev/null +++ b/src/main/java/dev/vality/beholder/service/SeleniumService.java @@ -0,0 +1,118 @@ +package dev.vality.beholder.service; + +import dev.vality.beholder.converter.LogEntriesToNetworkLogsConverter; +import dev.vality.beholder.model.*; +import dev.vality.beholder.util.SeleniumUtil; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.logging.LogEntries; +import org.openqa.selenium.logging.LogType; +import org.openqa.selenium.remote.DesiredCapabilities; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class SeleniumService { + + private final URL seleniumUrl; + private final String formUrl; + private final Long formTimeoutSec; + private final DesiredCapabilities desiredCapabilities; + private final LogEntriesToNetworkLogsConverter logEntriesToNetworkLogsConverter; + + @Getter + private final Browser browser = Browser.CHROME; + + public FormDataResponse executePaymentRequest(FormDataRequest formDataRequest, Region region) { + DesiredCapabilities capabilities = new DesiredCapabilities(desiredCapabilities); + updateCapabilities(capabilities, region); + + RemoteWebDriver driver = null; + try { + driver = new RemoteWebDriver(seleniumUrl, capabilities); + driver.get(prepareParams(formDataRequest)); + + //noinspection rawtypes + ArrayList performanceMetrics = (ArrayList) driver.executeScript(SeleniumUtil.PERFORMANCE_SCRIPT); + fillAndSendPaymentRequest(driver, formDataRequest.getCardInfo()); + + LogEntries les = driver.manage().logs().get(LogType.PERFORMANCE); + List networkLogs = logEntriesToNetworkLogsConverter.convert(les.getAll()); + + return FormDataResponse.builder() + .networkLogs(networkLogs) + .request(formDataRequest) + .formPerformance( + FormDataResponse.FormPerformance.builder() + .requestStartAt(castToDouble(performanceMetrics.get(0))) + .responseStartAt(castToDouble(performanceMetrics.get(1))) + .responseEndAt(castToDouble(performanceMetrics.get(2))) + .domCompletedAt(castToDouble(performanceMetrics.get(3))) + .build() + ) + .region(region) + .browser(browser) + .build(); + } catch (Exception e) { + log.error("Error during sending request from {}:", region.getCountry(), e); + } finally { + if (driver != null) { + driver.quit(); + } + } + return FormDataResponse.builder() + .region(region) + .browser(browser) + .failed(true) + .build(); + } + + private void updateCapabilities(DesiredCapabilities capabilities, Region region) { + capabilities.setCapability("browser", browser.getLabel()); + capabilities.setCapability("geoLocation", region.getCode()); + capabilities.setCapability("name", String.format("Payment from %s", region.getCountry())); + } + + private void fillAndSendPaymentRequest(RemoteWebDriver driver, FormDataRequest.Card card) { + WebElement cardNumInput = new WebDriverWait(driver, formTimeoutSec).until( + ExpectedConditions.visibilityOfElementLocated(By.ById.id("card-number-input"))); + cardNumInput.sendKeys(card.getPan()); + driver.findElementById("expire-date-input").sendKeys(card.getExpiration()); + driver.findElementById("secure-code-input").sendKeys(card.getCvv()); + driver.findElementById("card-holder-input").sendKeys("Ivan Ivanov"); + driver.findElementById("email-input").sendKeys("test@test.com"); + driver.findElementById("pay-btn").click(); + new WebDriverWait(driver, formTimeoutSec).until( + ExpectedConditions.visibilityOfElementLocated(By.ById.id("success-icon"))); + } + + private Double castToDouble(Object object) { + if (object instanceof Double) { + return (Double) object; + } else if (object instanceof Long) { + return ((Long) object).doubleValue(); + } else { + log.warn("Unable to cast {} to double", object); + } + + return Double.NaN; + } + + private String prepareParams(FormDataRequest formDataRequest) { + return UriComponentsBuilder.fromHttpUrl(formUrl) + .queryParam("invoiceAccessToken", formDataRequest.getInvoiceAccessToken()) + .queryParam("invoiceID", formDataRequest.getInvoiceId()) + .build() + .toString(); + } +} diff --git a/src/main/java/dev/vality/beholder/util/FileUtil.java b/src/main/java/dev/vality/beholder/util/FileUtil.java new file mode 100644 index 0000000..84b9067 --- /dev/null +++ b/src/main/java/dev/vality/beholder/util/FileUtil.java @@ -0,0 +1,22 @@ +package dev.vality.beholder.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.vality.beholder.model.Region; +import lombok.experimental.UtilityClass; +import org.springframework.core.io.Resource; + +import java.io.*; +import java.util.List; + +@UtilityClass +public class FileUtil { + + public static List readRegions(Resource resource) throws IOException { + final ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue( + resource.getFile(), + new TypeReference<>() { + }); + } +} diff --git a/src/main/java/dev/vality/beholder/util/MetricUtil.java b/src/main/java/dev/vality/beholder/util/MetricUtil.java new file mode 100644 index 0000000..527ea11 --- /dev/null +++ b/src/main/java/dev/vality/beholder/util/MetricUtil.java @@ -0,0 +1,58 @@ +package dev.vality.beholder.util; + +import dev.vality.beholder.model.FormDataResponse; +import dev.vality.beholder.model.NetworkLog; +import io.micrometer.core.instrument.Tags; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class MetricUtil { + + public static String getNormalisedPath(NetworkLog networkLog, String invoiceId, String invoiceToken) { + String path = networkLog.getResource(); + path = path.replaceAll(invoiceId, "{invoice_id}") + .replaceAll(invoiceToken, "{invoice_token}"); + if (path.contains("nocache=")) { + path = path.substring(0, path.length() - 13); + } + return path; + } + + public static double calculateRequestDuration(NetworkLog networkLog) { + return calculateDiff(networkLog.getStart(), networkLog.getEnd()); + } + + public static double calculateWaitingResponseDuration(FormDataResponse.FormPerformance performance) { + return calculateDiff(performance.getRequestStartAt(), performance.getResponseStartAt()); + } + + public static double calculateDataReceivingDuration(FormDataResponse.FormPerformance performance) { + return calculateDiff(performance.getResponseStartAt(), performance.getResponseEndAt()); + } + + public static double calculateDomCompleteDuration(FormDataResponse.FormPerformance performance) { + return calculateDiff(performance.getRequestStartAt(), performance.getDomCompletedAt()); + } + + private static double calculateDiff(Double from, Double to) { + if (isNumber(from) && isNumber(to)) { + return Math.round(to - from); + } + return 0.0; + } + + private static boolean isNumber(Double value) { + return value != null && !value.isNaN(); + } + + public Tags createCommonTags(FormDataResponse formDataResponse) { + return Tags.of("browser", formDataResponse.getBrowser().getLabel(), + "region", formDataResponse.getRegion().getCode()); + } + + public String getCounterId(FormDataResponse formDataResponse) { + return String.join("_", formDataResponse.getBrowser().name(), formDataResponse.getRegion().getCountry()); + } +} diff --git a/src/main/java/dev/vality/beholder/util/PaymentsUtil.java b/src/main/java/dev/vality/beholder/util/PaymentsUtil.java new file mode 100644 index 0000000..1c19ab1 --- /dev/null +++ b/src/main/java/dev/vality/beholder/util/PaymentsUtil.java @@ -0,0 +1,136 @@ +package dev.vality.beholder.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.vality.swag.payments.model.*; +import lombok.experimental.UtilityClass; + +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +@UtilityClass +public class PaymentsUtil { + + public static final String TEST_CARD_PAN = "4242424242424242"; + public static final String TEST_CARD_EXPIRATION = "12/24"; + public static final String TEST_CARD_CVV = "123"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + public static final String CURRENCY_CODE = "RUB"; + + public static PartyModification createShopModification(String shopId, String contractId, String payoutToolId) { + return new ShopCreation() + .details( + new ShopDetails() + .name("OOOBlackMaster") + .description("Goods for education")) + .location( + new ShopLocationUrl() + .url("http://all-time-favourite-spinners.com/")) + .contractID(contractId) + .payoutToolID(payoutToolId) + .shopID(shopId) + .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPCREATION) + .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION); + } + + public static PartyModification createShopAccountCreationModification(String shopId) { + return new ShopAccountCreation() + .currency(CURRENCY_CODE) + .shopID(shopId) + .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPACCOUNTCREATION) + .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION); + } + + public static PartyModification createShopCategoryChangeModification(String shopId, Integer categoryId) { + return new ShopCategoryChange() + .categoryID(categoryId) + .shopID(shopId) + .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPCATEGORYCHANGE) + .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION); + } + + public static ClaimChangeset buildCreateShopClaim(Integer paymentInstitutionId, String shopId, Integer categoryId) { + PartyModification contractCreation = createContractModification(shopId, paymentInstitutionId); + PartyModification contractModification = createPayoutToolModification(shopId); + PartyModification shopModification = createShopModification(shopId, shopId, shopId); + PartyModification categoryChange = createShopCategoryChangeModification(shopId, categoryId); + PartyModification accountChange = createShopAccountCreationModification(shopId); + + ClaimChangeset changeset = new ClaimChangeset(); + changeset.add(contractCreation); + changeset.add(contractModification); + changeset.add(shopModification); + changeset.add(categoryChange); + changeset.add(accountChange); + return changeset; + } + + public static PartyModification createContractModification(String contractId, Integer paymentInstitutionId) { + return new ContractCreation() + .contractor(createContractor()) + .paymentInstitutionID(paymentInstitutionId) + .contractID(contractId) + .contractModificationType(ContractModification.ContractModificationTypeEnum.CONTRACTCREATION) + .partyModificationType(PartyModification.PartyModificationTypeEnum.CONTRACTMODIFICATION); + } + + public static Contractor createContractor() { + return new RussianLegalEntity() + .registeredName("testRegisteredName") + .registeredNumber("1234567890123") + .inn("1234567890") + .actualAddress("testActualAddress") + .postAddress("testPostAddress") + .representativePosition("testRepresentativePosition") + .representativeFullName("testRepresentativeFullName") + .representativeDocument("testRepresentativeDocument") + .bankAccount(createBankAccount()) + .entityType(LegalEntity.EntityTypeEnum.RUSSIANLEGALENTITY) + .contractorType(Contractor.ContractorTypeEnum.LEGALENTITY); + } + + public static PartyModification createPayoutToolModification(String contractId) { + return new ContractPayoutToolCreation() + .payoutToolID(contractId) + .currency(CURRENCY_CODE) + .details(new PayoutToolDetailsBankAccount() + .account("12345678901234567890") + .bankName("testBankName") + .bankPostAccount("12345678901234567890") + .bankBik("123456789") + ) + .contractID(contractId) + .contractModificationType(ContractModification.ContractModificationTypeEnum.CONTRACTPAYOUTTOOLCREATION) + .partyModificationType(PartyModification.PartyModificationTypeEnum.CONTRACTMODIFICATION); + } + + public static BankAccount createBankAccount() { + return new BankAccount() + .account("12345678901234567890") + .bankName("testBankName") + .bankPostAccount("12345678901234567890") + .bankBik("123456789"); + } + + public static InvoiceParams createInvoiceParams(String shopId) { + return new InvoiceParams() + .shopID(shopId) + .dueDate(OffsetDateTime.now().plusDays(1)) + .currency(CURRENCY_CODE) + .product("Order num 12345") + .amount(1000L) + .metadata(MAPPER.createObjectNode()); + } + + public static String getRequestId() { + return RandomUtil.generateString(8); + } + + public static String getRequestDeadline(long seconds) { + return ZonedDateTime.now() + .plusSeconds(seconds) + .format(DateTimeFormatter.ISO_INSTANT); + } + +} diff --git a/src/main/java/dev/vality/beholder/util/RandomUtil.java b/src/main/java/dev/vality/beholder/util/RandomUtil.java new file mode 100644 index 0000000..29be612 --- /dev/null +++ b/src/main/java/dev/vality/beholder/util/RandomUtil.java @@ -0,0 +1,22 @@ +package dev.vality.beholder.util; + +import lombok.experimental.UtilityClass; + +import java.util.Random; + +@UtilityClass +public class RandomUtil { + + private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final Random STRING_RANDOM = new Random(); + + public String generateString(int length) { + StringBuilder builder = new StringBuilder(length); + + for (int i = 0; i < length; i++) { + builder.append(ALPHABET.charAt(STRING_RANDOM.nextInt(ALPHABET.length()))); + } + + return builder.toString(); + } +} diff --git a/src/main/java/dev/vality/beholder/util/SeleniumUtil.java b/src/main/java/dev/vality/beholder/util/SeleniumUtil.java new file mode 100644 index 0000000..b23abc8 --- /dev/null +++ b/src/main/java/dev/vality/beholder/util/SeleniumUtil.java @@ -0,0 +1,40 @@ +package dev.vality.beholder.util; + +import lombok.experimental.UtilityClass; +import org.openqa.selenium.logging.LogType; +import org.openqa.selenium.logging.LoggingPreferences; +import org.openqa.selenium.remote.CapabilityType; +import org.openqa.selenium.remote.DesiredCapabilities; + +import java.util.logging.Level; + +@UtilityClass +public class SeleniumUtil { + + public static final String PERFORMANCE_SCRIPT = """ + var data = new Array(); + var navigation = window.performance.getEntriesByType("navigation")[0]; + data[0] = navigation.requestStart; + data[1] = navigation.responseStart; + data[2] = navigation.responseEnd; + data[3] = navigation.domComplete; + return data;"""; + + public static DesiredCapabilities getCommonCapabilities() { + var capabilities = new DesiredCapabilities(); + capabilities.setCapability("build", "Simple payment test"); + var logPrefs = new LoggingPreferences(); + logPrefs.enable(LogType.PERFORMANCE, Level.ALL); + capabilities.setCapability(CapabilityType.LOGGING_PREFS, logPrefs); + return capabilities; + } + + public static DesiredCapabilities getLambdaTestCapabilities() { + var capabilities = getCommonCapabilities(); + capabilities.setCapability("network", false); + capabilities.setCapability("visual", false); + capabilities.setCapability("video", false); + capabilities.setCapability("console", false); + return capabilities; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..69ba07a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,62 @@ +server: + port: '@server.port@' + +management: + server: + port: '@management.port@' + metrics: + tags: + application: '@project.name@' + export: + prometheus: + enabled: true + endpoint: + health: + show-details: always + metrics: + enabled: true + prometheus: + enabled: true + endpoints: + web: + exposure: + include: health,info,prometheus + +spring: + application: + name: '@project.name@' + output: + ansi: + enabled: always +info: + version: '@project.version@' + stage: dev +logging: + level: + root: INFO + +schedule: + cron: '0 */5 * * * *' #every 5 minutes +keycloak: + url: http://127.0.0.1 + user: user + password: admin + resource: external +payments: + api-url: http://127.0.0.1 + api-timeout-sec: 30 + form-url: http://127.0.0.1 + form-timeout-sec: 30 + request: + shop-id: 1 + create-shop-if-not-found: true + payment-institution-id: 1 + category-id: 1 +selenium: + url: http://127.0.0.1 + port: 4444 + use-external-provider: false + regions: + - BR +dictionary: + regions: classpath:regions.json diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..51188b4 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,140 @@ + + ` ` + `` ` ` ` ` + ` ` ,***,``` + :*; `;*:```` + :i` ` .i*``` + `.*` ``..` :*`` + `*`` `:#zz, `*,`` + ,; ```nx#i#` .+.` + :: ``nxz#+` `z,`` + :;` `:#nz: ` +:.` + ,*`````.,,` #;.` + .+,` `` `` `n;.` + .i;```` ```` i+:.` + `.z:. ` ` ``i#;:`` + `.+#:,````:#+*:,` + ``:*z+z#+Mn+i;,`` + `.+;x@+n**;:,.` + `i;nzi#*::.`` + ``:;*z**;,.` + :iiiz;:.` + `;:,:#;,` + `*i;*+;.` + ` ` ::;,i+:,` ` ` ` `` ` + `````` `;:..:*:.` ` ` .`` ` + `:*;;+i` ` ` `*,`.*+:.` ```i*;i*,` `` + `,;+;;+#i;*i.`` *,,`i**,. i*` ```ii. ` `.`` + `:ii;:::*nM#;:i+; .i,``.ni.` ;:``+##. i;``:*;;;i*,`` + ` ```` ,++:```+nx; ` :i.````:i:`.i+:.` .i``ixxzz.`+i+;:;,,,`;*:`.::;i+z++i+i*+**i.`` ` + `````ii:`.:;M#,` `` `i:`` `*i` .#i:.` i: `*xx#z,`,+;,.` ` ` `*#i:ii:++##zi.````:*.`` + `` ` ` `:;:``#++M# ``..``,i.``,*;``i#;,` #, .+zx*.` z``` .````i;``,`.,;;nn`````` .*.``` + ` ` ``````:*. .**nM;```:z#i ,*,``*;, ,;#:.` `#. ``::````* ``++n+`*:````.i;+#x: ```````*,.`` + `,:::::.```+;.`,:n#x: `:zzn#.:+,.,;+. `**:.` `+, ` ,;,:;:::;n, ``.:i;i;:i` ``.:,``.+.` + ,*::,.,:i,``*;` ;*z+z, `inz*#.*+,.:ii, `+;,. ` ``#. ```` `*.i:####z:` .,+#####: ` ;#zz: `z:`` + ` ` `:i```` ```*,.*i` .,zi+:``:nxz;;zi,.+*i.``+;,` `,z:` ` `:+...x**i+:, `.*ii#ii;;#. `,zM*i#``z:.` + ,i ``,**.`,#,i:i ``n;;+ ``:;:;z*;,;;*::``+;,` ,*;*:` .:iz. .i+i:;+:. `*. :#;,,,*, :zWz##``n:.` + i.` `;xn#n` i;ii#. :#;;zi;;i##*;,.+:*+*``+i,```.,;;i..+@@#i*i+```:#i;,+,, `;n.`.*;.`.i;` `*zxz:`*+:.` + ` `.* ``.zn+,#:`;*:#:+ `:#:;*+##*i;..`++M#ii;++**ii;::. .,iz#:z*+: ,zi:;i:```x:z.`+:.``,+`` `:::`izi,.` + ;.` .znWzn,`:*:;*ii` .*i:;;::,.`,i*,`` ` ` ` `,iz;in#:i ```+:;*,, +#ii. +:.```,z: ``.:+#i:.`` + i` ` :#nn+` :*::*:*,` `,**,.,,.`i;...``` `` ` `,:*;;z#*#.i`` `#;+:: ``#++;, ;i.```.;*#####+i;,` + `i` ` ``````;+:.,#:;,```;**,.`.*i.`` ` ` ` ``,,:*;+z*i;*,z; z+.` ;#iz;i`.z,.` `.,;iiii;:,.` + ` ,i ` ``````zi,..:#;+:`.`#;#..+*i:` `..,..,`,:+z+#+i;:,;:i. ` :+,` ` **;z:#..z:``` ..,::::,.`` + ``*: `` ,n#i,`..:#;*i.:,Mi##;+..``` `,:**ii++nx#**i;;:,...+,+.;+:`` .ni:#;n+:i+i,` ``.``.`` + `:#, `.i*#z*:,``..:+;#x*,**z*,.` ..*i;:;;##*i;i:::,.````*in::` `` `+*;,*iWx;:;:i:`` ```` + `.;*zznW+#n+i;,.` `.;z;#x++W:#..` `;:zi,.```,+*;,,...``.::i*, ` ` *#;:.*#;` ``` +,``` + ``:i+xxi+#*i;,.`` `*+ii**xxni+#.````,,x:;:.` `..+:,````:*;:,``` ` ` i#*:,`#,` `````;i``` + `,;;;i;#*;:,```:ii.,,:;:izn:#:... `:inii:.:. `:i:.``:*: ..:` ` ` `,##i;,.`i` `:i;` :*.`` + ``+``.;#;:`.`;i:,+:,,:,,n*++:`..` .,++#,n,.` `..+,`*:`.:,.` ` ,,;+n*i:.`.i``;nnn*`:*,.` + `*:` .,#*ii+, `..,,`:.,,z:,#;.` ;:ni+:nz;.``i;z;i.:*i;.` `.;:#*;*,`#i:. `* `nMi*+`;+,.` + `:i,:` ``` ` ``;:,.,:,x:**i.. *:zi*;i+i:. ::+#.,+#;` `;,;;#i:` ;+,.` `# `+Mnn*`#i,.`` ` ` + ` `.i+ii,` ` ` `.;i#*n+zz*+,*i*.. `+:*ii;+ni.,,:i*,i#+::, `::;*n*zi` `,*,` `,*..*+*.*+i,` ` ` ` ```:;**;,`` `` + ```.i#;;i;i,:i*z**:;ii;;#n:+i,:, .++;+;i+M#i;:ini,xi*;..,;:##++;z*`` ..#,.` `.*+;.,*z#i:.````` ``i:. ,;+.` ` + ` `.,;#+;;:ii;ii*znz++zz,#**i;+:,;*n;+;i+zi**#+#`inn;+*:;,n#*;*,xi. ` .*i,` ``,:*z#*ii:.`` ```.;*,```` `:*`` + ` `i++++#znn+++###+***i;;;i+*z:#+z,:#;++:ni#nnz+zx*`+nMnx:ii+z*###:x;.```,,+,.:i++#zz*;i:.`` .:*i;i:*.```,,```z..` + ,i*.` `.:++#*;;;::,,,.,:i*W:M+,i:i*iz#;+M@Mz@#M:+WznWnz#:x@@z#;zx#,.,,;.n+i::::,.,;+;.``:*i.`:i;**``.#xz#` :*.` + ` ;+,. ` `````i:*+:,,....```i;@+;ii:++:+Wn#;n@@xzzx#i;,.,;+M+zWxW#;M*i;;:;+.*;*;#****;;,;i,i;i` ``:ni``;xn:z,`.+,`` + .;i,` `..` ` ``;,,*i,` ` ` `;i@@,zxWii#z+WWW#,. ``,` `` `:i+x*:zn*i:i;*#:##;:,::,```,`:#i+.```.,+@; `:nxnz, .z,`` + `,i;` ..,;;,..`. `` ,;;i*;.`` `;z##+,xznzzMW@z:` ` . `.``.;M#zx+i:i*#.n+::,,:` ``++,``.*:;i#;``.:zz;``:#:`` + i;,`;:izn#zzzi,:.`` ``:,;****izn@@W*;MxMMn:,` ` .,;:,i*:;;,:,` ` ``#WxMnWzz#.ni;:.`` ```` `,i.,;:;iiii;``````` `zi,. + .**`*.+z*ii;ii+#*:#i`` ``:.`,**+*#MMzi:+xi``. ```:;+nnMnx#M+#**:`` ``:xWx@nM:iz:::,:.`` `*i;:##+++#n````````;z;,`` + `` ``**z#;+#i;:,,,::;*#:+#i``` `;:+#+#Wx#*: ` `.:,*zMMxxnnzznnxMWz*;,`` .MMWMz,nW++nn#ii;: ` *;;n*i;:;;i+. .ini:.` + `` `*+MMx;ni:,.```..,:i+#i#xi*:` .;;*Wzi.`.. ` ,**zzi;i*++**##*:;nWx#i,` `iWxn;*MzzzMi,,;,,, :+z*;:,..,,*z,,,+z#i;,.` + `` `.*zi;+xW*#;,.` ```,;in*i#+*:,..;;*##*``` . .`;;ixn+i+i` ``` ``;+;#+zWz:` `:W#,WMMzz,:x@nz#,.` :x*;:.````.,:+z#+ii;,.` + `` :*:` ```**+;.` ` .i+***+*#:i#zxi+nxz@` ``` `;z*MM,zi:` ` ``*``` ,i**@xz;;.,,#+MMM#.;W@@x*#;. `i+i:.`` `.,:;i;::.`` + ``,* `` ` ,+:` ,ii,;i*+#MxW#i;*zxxn@# ` .ii;#z#x*x: `` .zM+` ` *+:z@M**. :MWx+inM#W#n@M+: ` zi;.` ```...,.`` + ``*` ``` ``z:` i;. ``:**+zWxW@Wxz*:;n+ ;;M,nWz#W:` ``` #Mnx,` ` `;M*;#+#;.`:;#W@+i+###WWz*```#;,`` ` ```````` + :; ``,,.` +;.` `:*` ```ii#;#xn####*x@@Mz `.iiMn.*MW;,` ` ` ;Mxznn. ` `.;Wz.M@i:,,`.x##*@zzW#@x, .,#;,` ``` ` + ::``+#z#, ;*.` +i` `**:*+M+x##@#x@MM#n``i+#@i+M@*:,. ```znnznxi`` `,;@z:x@+,,.`z+MzxWn*##ni..+:+,.:**+; ``` + ;,`;xx.+#` ,*.. ,*:` ``;#n#*++;zMnzWxz+i;:,+zW#+znW:,,, ``;nnnxnnn`` ,ixzM:W#zi: *W*i##Wx#n#,,`:i*+*:` .+:`` + ;:`;nx,+z. ,*,.`,++ ```z+***+zzi,;**i::*;;iixx*z+@#`;:.` ``+xnnMnzM``` ` .:#+n#;+i*``,n*iz#;;*nz;:`;M#+, `#.`` + .:;``+MMn* ` i+:.``++;.`.*+;::;*iz#:.``,..;.i+i,*zMx:;.:.` ``znnx@xnx.`````.;,Mixi#@*,.,i#:`,i#*n+#,:;*#` ` ``` `+.` + `.#` .:ii`` `zi,``.iin+;;;*+,,,;+;zWxz*#:,`,#xn;xi@#+,``.` `MnnM@nnx` ````,:;xin+;*+:`:+` :+MiMWin:+#+;` ``.:. `i:`` + .,z;``` ``:n*;.```,+innnM+,*+*i*x*;zxx*::`*+W:#ix@+,.:..`` `xxnMx;nx` `` .,i*M+M+:xW+;n;+xx:#M@z+#.#M` `+nz#``;*.` + `,;*+:.,*z+*i:.```.:+i@#,` `,#MWz#+*:i.,,;;;i#xM,,,;..` ``zxnM,:nn `` ;#i:Wzz*,#z+:,+#z:++#@#z++`z. ,xM*#:`;*,` + ````,:i+##*ii;,.`` `.;++` ``` :@Wn#*i;:`,:i*,;i*#*;*,.,:.` *Mxxi#x+`````.i,,iMzii`:;;*;*z#z:*ix#z*i#,,` ;nMin:`;+,. + ```.::;;;;:,.. ` ` `:#.` ````` WWzM++;ii;i+:.:zi#n*+##n+i:.;xWM@MMz:,,;iiz+nMnz;i*xW@n#;::i**#MM##i*z,. ,xnnz``;+,. + ...,..``` `;:`` :nxx* xWn;*+@#xnn;.,#z+n+ii*+*;inzzi,..`.,:;:..:+x@z:;i,*nznznzznzz##nWW*zzz+.``.;i.` +i,` + ``````` `;,` ,zn#ix. xW+;:#n*+W*,:;,+:;i;::,,:.`,.`.`,i;i##ii;*,.*i,.,::++#i+Mxz#zW;*#WMx###;```````:z;,` + `i,``:nx+*n.`xi+;#i*##. .`i, ``.:;;,,` ` `,`,;::,:..`` , ` ,``,*ii.,#z;+***n@Mz*+++,````*zi:`` + `;:``:znMx#`,i+zi;i:;:````:. ```....,,:` ,;i,` ` `` ` ;.```iii.;:::z#+n@x*+;++zz#z##*:,`` + `:i `*#z+,`#+n**#.**.,*`,, `` ` `` .,:*+nn,.` ` `:z` `i..ii*;;;*+zn*#n#*:;iii*ii;,``` + .*: `..``+++;i+`+zi,*,`:` ` ` ``` `..,,*+*;:.` .```,zzxi :# .n;ii;i,+##x+i#;,::;::,,.` + ..++::,,*M##.ii.`#M::i.., `i;i:;:. `` ` ` ` ````` ` ``` `i*` ;:.n:+,:*`;nx++n+i,.....``` + `.:i++zzWn;;.i:..n#:.:`.` ::;.```` `..` ```. .`` ` ,` ``+ ` .`.:#,+::,i;nn+*;+;.```` + ``.,:;#xWx;* ::`.+x;,.` `` ` ., .,`. .. ` .. `+.` `:,`*@,z.;;z`nx+;:,..` + ``,,+xxW#;.:+: *nz,*.,`,`. ` ,` ;ni .:,`:i;.`` :````#` ``,:`xn,+:;**`nni;,.`` + ```*zzxnMM:;`iM..+@:.,: ,;..;, ,.,#* iMx;zMxnzn+;:...: ::i;`; ``,nMi*`i.#*.n@;+,`` + `;nz#zM*Mi;,.n#`+@,` :`.i:;`.;i+:+ni ix, .xMMi``x#z+nz;.`iii*.,``ni:,i,,*: z#n*;;;` + `;*;i++n,z+:: ;x:*W:` :#:;:.;xzx+ :#;`*M, `#@#; W@ xzzM#*:nz*i,+z;#x:,*:*`,Mx#M+:`` + ..,:;++,+ii+ .z+,M,;:`;i:inz#:M+`,#i iW, :@##; #x `@#@x`:+;;#zxz*nx:`#.+.`.:#nzi,.` + ``.,*+:+i#;in;:x +,,*z+:+x:.i`nz i@n ;@; ###@; .#z :##@n `Mn*zn*niW::z:i#.,;.,xi;,` + ` ``:;,x:.:n#ix,#,,#*nn*;*#:`# zz`+#@ ,@i n##@; :@z ##@#z `n*#xx#z#z.n,`z#,*iz`++:,` + ` :;i*,:` :nx:*.ni**z#;+i`z`+`#W n##, @+`z###; i#*.x#### ii+*MM#:+:`i, in:i;*i,#:.` + ` `i.;+nnn*`:M**,*#*W+Mz,+`*;i,*#`n### Mz`n###i z#;;@@##i`z:;xWni:#.:,:,i.``..*:i;.`` + ` `;::*iii+z#,*M::.n;xz@+z;i.#:ii#;+#@W;+M`n###*.xM,n###M::i;iMMz,**`i;i,`:+#+:.;:*;`` + `***+n#M+n;z,*:*`**+z@+Wi#.+:z:@#i####i#`n###*:Wn.@##@n`z+:MMM+:+,:+*:`;Mx+;;::,,+.`` + ` `,:i;#x.zni+:+,,.#.zM+xW*n.n#:xx:####:x:z###+i#*+##@#;*+;n#xx,:z`#;# ,M+,n#+;:,`*:.` + `.,,;;+,n#z,.*.; *`*xzx#WWi*z#n@;##@##niz###++#:M##@W:Mn#@#n#.;z.*,#.in`+#n+z+++:i.` + `````*iz@MW;.,;,::,i##x###@;x@@@zn##@Wnnz###+Mzz###@*WW+@##n,;;z,+.z.#,z#x;n*i**n;,` + ````+#*nW#x,`z`:;,;;#M###@x+##@@####@@x####z@n#@##@W@x@##@n`;iW;#;+;:#Wx;x+#;::n:.` + `.,+nx*M#;x.:#i.*;@####@xM@########@n###W####@#########W+,*Wi#x,,#+i,*#i:,..,..` + .,i;MW+M:x.+;n;i;####@#@@@##@###@##W#######@#####@#####+*#M#;x:#,:i;z*;:.``.``` + ``,**##n;W:;+zi;;####@#####@#@###@##########@###@##@###*ziMM*+x;.#z#*;,.`` ` + `.:*MWx;x#;z+i*#######################@###@##@#####@@#iMizni*zi.+i#;:.` + ``.+nMM:xx:#++#MW@######@#######################@@W##@izi*#nzn, ;n*:..` + ``*nMM,zM:@*z#W+@##@##M@################@###@###W*##@n*i;+#M*,.,,#:. ` + `**Mxi*x+n##zxi#@#@zW##########W@##########z@###*+##xzii#*@::Mz+;*.` + `nx+zn;+z+z,xn,####M########@W##W####@#WW##*@@###,W#@++#Mzni;+z+*;.`` + `,;+xin:iWM,n+,W#@:#iz#######W#;W######x###ixxx#M`n#@z:znxn,#x#*;,.` + .,#+z*:;x#;+;,#@x,W:n######i#W:W######x;##innn#@ #iz#`W@nz:W*+;:.`` + `.ii#*,n+#M,#i;@n.x.n#####W:#z,M######M.##;#x*##`i*n.+#zxiM##i:.`` + ``,+#:.+;@@#,*#nn`n`z#####n`@#.x######M,W#iix,##`iin.xWxMxni#;,` + ``.:+,:::W#@+.+@# n`*#####n #*`z######M,n#i,x.@n i#z;MWWz++#i:.` + ` `.+.;`+@###,iMi`n`i##@##z #i z####@#M,z#:`M,M+ i#.;WMMxxi#;,` + ` `;iiinM##@M+,#zin`,x@###* @: *######M.+@``x.n+;M`.+@#M#W;+:, + `.;*#zzW##@#ii,@Mn` +@###:`x: :######n`#z `x`#iM+`i##WWW;+i:. + ;i*+#x+M#W@z*i:#n, ;xx#@. #: .#####@# #i` Wn*#`,,i#@W@z**;.` + .,:;*+zz#W+zi#.`:zn+..;x +; `######,`++ ,+#i`;;#@@@@Mi#i:.` + ``.,,:;++#W@#**+,` *n@#i#,`+i .xnnn,#.`*n*+*+ :*M@M@#@zn*;,`` + ``...,:*#+#@*nzi; .,;ix+i+zx#+.:,:i,##*+nn*i`i+#x@###*#i:. + ` ```.,;z#MWi@#+zi,.``iz*Mn;;M@#@#@;:+x*..:i:W###xi+,;i,` + `.,;##zx+@x+++,;.,;,;+W@#M#*i*#zx: :;*ix+zW+*zz*z;.` + ``.:iz*xn@#zii+i+:,.`::.``.````:`:+#nW#nniz#**i;,.` + `.,*ziM@@#xz:::ix,i**;i;zz++i;;#+n#Mn###*i;::,.` + `.,;xi#xxW#n,;,,*#+*+*i+;i,.i#z@#M**z*i;:,.``` + ``:#++##+#z;xz:zM##Mz#n+*, x#@@M*##ii:,.`` + `,:;;*+zM;+#zz##++z@##+,;@@x++z*;:,.`` + ``,,,:;;zizz**#n@#@####.#z#++z*;:.`` + ```...,#;#+zznnzzz#++z:z#+#i*;,.`` + ``,*;#**iiiiiiii;+;+ii*+;,. + ``*+;::::,,,,,,,i;*;::,,.` + `.,,,..........i:i,,...` + ```...`` `+,*,.````` + ``` `;,+,.`` + ` .ii+;.` + `**z:.` + ``:::.` + ..,.`` + `````` + ` ``` \ No newline at end of file diff --git a/src/main/resources/regions.json b/src/main/resources/regions.json new file mode 100644 index 0000000..b28c296 --- /dev/null +++ b/src/main/resources/regions.json @@ -0,0 +1,414 @@ +[ + { + "code": "AL", + "country": "Albania" + }, + { + "code": "AD", + "country": "Andorra" + }, + { + "code": "AR", + "country": "Argentina" + }, + { + "code": "AM", + "country": "Armenia" + }, + { + "code": "AU", + "country": "Australia" + }, + { + "code": "AT", + "country": "Austria" + }, + { + "code": "AZ", + "country": "Azerbaijan" + }, + { + "code": "BH", + "country": "Bahrain" + }, + { + "code": "BD", + "country": "Bangladesh" + }, + { + "code": "BY", + "country": "Belarus" + }, + { + "code": "BE", + "country": "Belgium" + }, + { + "code": "BA", + "country": "Bosnia and Herzegovina" + }, + { + "code": "BR", + "country": "Brazil" + }, + { + "code": "BG", + "country": "Bulgaria" + }, + { + "code": "KH", + "country": "Cambodia" + }, + { + "code": "CA", + "country": "Canada" + }, + { + "code": "CL", + "country": "Chile" + }, + { + "code": "CN", + "country": "China" + }, + { + "code": "CO", + "country": "Colombia" + }, + { + "code": "CR", + "country": "Costa Rica" + }, + { + "code": "HR", + "country": "Croatia" + }, + { + "code": "CW", + "country": "Curacao" + }, + { + "code": "CY", + "country": "Cyprus" + }, + { + "code": "CZ", + "country": "Czech Republic" + }, + { + "code": "DK", + "country": "Denmark" + }, + { + "code": "DO", + "country": "Dominican Republic" + }, + { + "code": "EC", + "country": "Ecuador" + }, + { + "code": "EG", + "country": "Egypt" + }, + { + "code": "SV", + "country": "El Salvador" + }, + { + "code": "EE", + "country": "Estonia" + }, + { + "code": "FI", + "country": "Finland" + }, + { + "code": "FR", + "country": "France" + }, + { + "code": "GE", + "country": "Georgia" + }, + { + "code": "DE", + "country": "Germany" + }, + { + "code": "GR", + "country": "Greece" + }, + { + "code": "GT", + "country": "Guatemala" + }, + { + "code": "HN", + "country": "Honduras" + }, + { + "code": "HK", + "country": "Hong Kong" + }, + { + "code": "HU", + "country": "Hungary" + }, + { + "code": "IS", + "country": "Iceland" + }, + { + "code": "IN", + "country": "India" + }, + { + "code": "ID", + "country": "Indonesia" + }, + { + "code": "IE", + "country": "Ireland" + }, + { + "code": "IM", + "country": "Isle of Man" + }, + { + "code": "IL", + "country": "Israel" + }, + { + "code": "IT", + "country": "Italy" + }, + { + "code": "JM", + "country": "Jamaica" + }, + { + "code": "JP", + "country": "Japan" + }, + { + "code": "JO", + "country": "Jordan" + }, + { + "code": "KZ", + "country": "Kazakhstan" + }, + { + "code": "KE", + "country": "Kenya" + }, + { + "code": "KR", + "country": "Korea" + }, + { + "code": "KW", + "country": "Kuwait" + }, + { + "code": "KG", + "country": "Kyrgyzstan" + }, + { + "code": "LV", + "country": "Latvia" + }, + { + "code": "LB", + "country": "Lebanon" + }, + { + "code": "LT", + "country": "Lithuania" + }, + { + "code": "LU", + "country": "Luxembourg" + }, + { + "code": "MY", + "country": "Malaysia" + }, + { + "code": "MT", + "country": "Malta" + }, + { + "code": "MX", + "country": "Mexico" + }, + { + "code": "MD", + "country": "Moldova" + }, + { + "code": "MN", + "country": "Mongolia" + }, + { + "code": "MA", + "country": "Morocco" + }, + { + "code": "NL", + "country": "Netherlands" + }, + { + "code": "NZ", + "country": "New Zealand" + }, + { + "code": "NI", + "country": "Nicaragua" + }, + { + "code": "NG", + "country": "Nigeria" + }, + { + "code": "NO", + "country": "Norway" + }, + { + "code": "OM", + "country": "Oman" + }, + { + "code": "PK", + "country": "Pakistan" + }, + { + "code": "PA", + "country": "Panama" + }, + { + "code": "PY", + "country": "Paraguay" + }, + { + "code": "PE", + "country": "Peru" + }, + { + "code": "PH", + "country": "Philippines" + }, + { + "code": "PL", + "country": "Poland" + }, + { + "code": "PT", + "country": "Portugal" + }, + { + "code": "PR", + "country": "Puerto Rico" + }, + { + "code": "QA", + "country": "Qatar" + }, + { + "code": "RO", + "country": "Romania" + }, + { + "code": "RU", + "country": "Russian Federation" + }, + { + "code": "SA", + "country": "Saudi Arabia" + }, + { + "code": "RS", + "country": "Serbia" + }, + { + "code": "SG", + "country": "Singapore" + }, + { + "code": "SK", + "country": "Slovakia" + }, + { + "code": "SI", + "country": "Slovenia" + }, + { + "code": "ZA", + "country": "South Africa" + }, + { + "code": "ES", + "country": "Spain" + }, + { + "code": "SE", + "country": "Sweden" + }, + { + "code": "CH", + "country": "Switzerland" + }, + { + "code": "TW", + "country": "Taiwan" + }, + { + "code": "TZ", + "country": "Tanzania" + }, + { + "code": "TH", + "country": "Thailand" + }, + { + "code": "TN", + "country": "Tunisia" + }, + { + "code": "TR", + "country": "Turkey" + }, + { + "code": "UA", + "country": "Ukraine" + }, + { + "code": "AE", + "country": "United Arab Emirates" + }, + { + "code": "GB", + "country": "United Kingdom" + }, + { + "code": "US", + "country": "United States" + }, + { + "code": "UY", + "country": "Uruguay" + }, + { + "code": "UZ", + "country": "Uzbekistan" + }, + { + "code": "VE", + "country": "Venezuela" + }, + { + "code": "VN", + "country": "Vietnam" + } +] \ No newline at end of file diff --git a/src/test/java/dev/vality/beholder/BeholderFullTest.java b/src/test/java/dev/vality/beholder/BeholderFullTest.java new file mode 100644 index 0000000..13a50af --- /dev/null +++ b/src/test/java/dev/vality/beholder/BeholderFullTest.java @@ -0,0 +1,193 @@ +package dev.vality.beholder; + +import dev.vality.beholder.config.properties.PaymentsProperties; +import dev.vality.beholder.config.properties.SeleniumProperties; +import dev.vality.beholder.security.KeycloakService; +import dev.vality.beholder.service.BeholderService; +import dev.vality.beholder.service.SeleniumService; +import dev.vality.beholder.testutil.ResponseUtil; +import dev.vality.swag.payments.ApiClient; +import dev.vality.swag.payments.api.ClaimsApi; +import dev.vality.swag.payments.api.InvoicesApi; +import dev.vality.swag.payments.api.PartiesApi; +import dev.vality.swag.payments.api.ShopsApi; +import dev.vality.swag.payments.model.Party; +import org.junit.jupiter.api.*; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.client.HttpClientErrorException; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@AutoConfigureMetrics +@AutoConfigureMockMvc +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"beholder.cron=-", //disables scheduled execution + "payments.request.shop-id=test", + "payments.request.create-shop-if-not-found=true", + "payments.request.payment-institution-id=1", + "payments.request.category-id=1", + "management.server.port="}) +public class BeholderFullTest { + + public static final String TEST_USER_TOKEN = "test_token"; + + @MockBean + private KeycloakService keycloakService; + @MockBean + private ApiClient apiClient; + @MockBean + private PartiesApi partiesApi; + @MockBean + private ShopsApi shopsApi; + @MockBean + private InvoicesApi invoicesApi; + @MockBean + private ClaimsApi claimsApi; + @MockBean + private SeleniumService seleniumService; + + @Autowired + public BeholderService beholderService; + + @Autowired + public SeleniumProperties seleniumProperties; + + @Autowired + public PaymentsProperties paymentsProperties; + + @Autowired + private MockMvc mockMvc; + + private AutoCloseable mocks; + + private Object[] preparedMocks; + + @BeforeEach + public void init() { + mocks = MockitoAnnotations.openMocks(this); + preparedMocks = new Object[] {keycloakService, apiClient, partiesApi, shopsApi, invoicesApi, claimsApi, + seleniumService}; + } + + @AfterEach + public void clean() throws Exception { + verifyNoMoreInteractions(preparedMocks); + mocks.close(); + } + + @Test + public void beholdWithExistingShop() throws Exception { + Party party = ResponseUtil.getMyPartyResponse(); + String shopId = paymentsProperties.getRequest().getShopId(); + + when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN); + when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party); + when(shopsApi.getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString())) + .thenReturn(ResponseUtil.getShopResponse(shopId)); + when(invoicesApi.createInvoice(anyString(), any(), anyString())) + .thenReturn(ResponseUtil.getInvoiceAndToken()); + when(seleniumService.executePaymentRequest(any(), any())) + .thenReturn(ResponseUtil.getFormDataResponse()); + + beholderService.behold(); + + + verify(keycloakService, times(1)).getUserToken(); + verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN); + verify(partiesApi, times(1)).getMyParty(anyString(), anyString()); + verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString()); + verify(invoicesApi, times(1)).createInvoice(anyString(), any(), anyString()); + verify(seleniumService, times(1)).executePaymentRequest(any(), any()); + + + var mvcResult = mockMvc.perform(get("/actuator/prometheus")) + .andReturn(); + String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + List metrics = Arrays.stream(prometheusResponse.split("\n")) + .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList()); + Assertions.assertFalse(metrics.isEmpty()); + } + + + @Test + public void beholdWithShopCreation() throws Exception { + when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN); + Party party = ResponseUtil.getMyPartyResponse(); + when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party); + String shopId = paymentsProperties.getRequest().getShopId(); + when(shopsApi.getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString())) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + when(claimsApi.createClaim(anyString(), any(), anyString())) + .thenReturn(ResponseUtil.getClaim()); + when(invoicesApi.createInvoice(anyString(), any(), anyString())) + .thenReturn(ResponseUtil.getInvoiceAndToken()); + when(seleniumService.executePaymentRequest(any(), any())) + .thenReturn(ResponseUtil.getFormDataResponse()); + + beholderService.behold(); + + + verify(keycloakService, times(1)).getUserToken(); + verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN); + verify(partiesApi, times(1)).getMyParty(anyString(), anyString()); + verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString()); + verify(claimsApi, times(1)).createClaim(anyString(), any(), anyString()); + verify(invoicesApi, times(1)).createInvoice(anyString(), any(), anyString()); + verify(seleniumService, times(1)).executePaymentRequest(any(), any()); + + + var mvcResult = mockMvc.perform(get("/actuator/prometheus")) + .andReturn(); + String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + List metrics = Arrays.stream(prometheusResponse.split("\n")) + .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList()); + Assertions.assertFalse(metrics.isEmpty()); + } + + @Test + public void beholdWithGetShopError() throws Exception { + when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN); + Party party = ResponseUtil.getMyPartyResponse(); + when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party); + String shopId = paymentsProperties.getRequest().getShopId(); + when(shopsApi.getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString())) + .thenThrow(new HttpClientErrorException(HttpStatus.I_AM_A_TEAPOT)); + + beholderService.behold(); + + + verify(keycloakService, times(1)).getUserToken(); + verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN); + verify(partiesApi, times(1)).getMyParty(anyString(), anyString()); + verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId), + eq(party.getId()), anyString()); + + var mvcResult = mockMvc.perform(get("/actuator/prometheus")) + .andReturn(); + String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + List metrics = Arrays.stream(prometheusResponse.split("\n")) + .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList()); + Assertions.assertFalse(metrics.isEmpty()); + } + + +} diff --git a/src/test/java/dev/vality/beholder/IntegrationTest.java b/src/test/java/dev/vality/beholder/IntegrationTest.java new file mode 100644 index 0000000..c7ff9f2 --- /dev/null +++ b/src/test/java/dev/vality/beholder/IntegrationTest.java @@ -0,0 +1,111 @@ +package dev.vality.beholder; + +import dev.vality.beholder.config.properties.SeleniumProperties; +import dev.vality.beholder.security.KeycloakService; +import dev.vality.beholder.service.BeholderService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static dev.vality.beholder.testutil.SystemUtil.isArmArchitecture; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@Disabled("Used only for local testing") +@AutoConfigureMetrics +@AutoConfigureMockMvc +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"beholder.cron=-", //disables scheduled execution + "payments.api-url=https://api.test.com", + "payments.form-url=https://checkout.test.com/checkout.html", + "payments.request.shop-id=test", "management.server.port="}) +public class IntegrationTest { + + public static final String TEST_USER_TOKEN = "test_token"; + + public static final String SELENIUM_IMAGE_NAME = + (isArmArchitecture() ? "seleniarm" : "selenium") + "/standalone-chromium"; + + public static final String SELENIUM_IMAGE_TAG = "101.0"; + + @Container + public static final GenericContainer SELENIUM_CONTAINER = new GenericContainer(DockerImageName + .parse(SELENIUM_IMAGE_NAME) + .withTag(SELENIUM_IMAGE_TAG)) + .withExposedPorts(4444) + .waitingFor(new HostPortWaitStrategy()); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + Supplier seleniumUrlSupplier = () -> "http://" + SELENIUM_CONTAINER.getHost(); + Supplier seleniumPortSupplier = () -> SELENIUM_CONTAINER.getMappedPort(4444); + registry.add("selenium.url", seleniumUrlSupplier); + registry.add("selenium.port", seleniumPortSupplier); + } + + @Autowired + public BeholderService beholderService; + + @MockBean + private KeycloakService keycloakService; + + @Autowired + public SeleniumProperties seleniumProperties; + + @Autowired + private MockMvc mockMvc; + + @BeforeAll + public static void prepare() { + SELENIUM_CONTAINER.setPortBindings(List.of("4444:4444")); + SELENIUM_CONTAINER.start(); + } + + @AfterEach + public void cleanUp() { + verifyNoMoreInteractions(keycloakService); + } + + @AfterAll + public static void stop() { + SELENIUM_CONTAINER.stop(); + } + + @Test + public void test() throws Exception { + when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN); + beholderService.behold(); + verify(keycloakService, times(1)).getUserToken(); + + + var mvcResult = mockMvc.perform(get("/actuator/prometheus")) + .andReturn(); + String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + List metrics = Arrays.stream(prometheusResponse.split("\n")) + .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList()); + Assertions.assertFalse(metrics.isEmpty()); + + + System.out.println("Collected metrics: "); + metrics.forEach(System.out::println); + } + + +} diff --git a/src/test/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverterTest.java b/src/test/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverterTest.java new file mode 100644 index 0000000..5c17159 --- /dev/null +++ b/src/test/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverterTest.java @@ -0,0 +1,50 @@ +package dev.vality.beholder.converter; + +import dev.vality.beholder.exception.BadFormatException; +import dev.vality.beholder.model.NetworkLog; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.logging.LogEntry; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.Instant; +import java.util.List; +import java.util.logging.Level; + +import static org.junit.jupiter.api.Assertions.*; + +class LogEntriesToNetworkLogsConverterTest { + + private static final LogEntriesToNetworkLogsConverter CONVERTER = new LogEntriesToNetworkLogsConverter(); + + @Test + void convertValidLogs() throws IOException { + String start = getMessage("valid_start_network_log.json"); + String stop = getMessage("valid_finish_network_log.json"); + LogEntry startLogEntry = new LogEntry(Level.ALL, Instant.now().toEpochMilli(), start); + LogEntry endLogEntry = new LogEntry(Level.ALL, Instant.now().toEpochMilli(), stop); + List converted = CONVERTER.convert(List.of(startLogEntry, endLogEntry)); + + assertNotNull(converted); + assertEquals(1, converted.size()); + NetworkLog networkLog = converted.get(0); + assertNotNull(networkLog.getStart()); + assertNotNull(networkLog.getEnd()); + assertTrue(networkLog.getStart() < networkLog.getEnd()); + assertEquals("https://checkout.test.com/v1/fonts/90d16760.woff2", networkLog.getResource()); + } + + @Test + void convertInvalidLog() throws IOException { + String invalid = getMessage("invalid_network_log.json"); + LogEntry logEntry = new LogEntry(Level.ALL, Instant.now().toEpochMilli(), invalid); + assertThrows(BadFormatException.class, () -> CONVERTER.convert(List.of(logEntry))); + } + + private String getMessage(String filePath) throws IOException { + var resource = new ClassPathResource(filePath, getClass()); + return FileCopyUtils.copyToString(new InputStreamReader(resource.getInputStream())); + } +} \ No newline at end of file diff --git a/src/test/java/dev/vality/beholder/service/BeholderServiceTest.java b/src/test/java/dev/vality/beholder/service/BeholderServiceTest.java new file mode 100644 index 0000000..6a41688 --- /dev/null +++ b/src/test/java/dev/vality/beholder/service/BeholderServiceTest.java @@ -0,0 +1,77 @@ +package dev.vality.beholder.service; + +import dev.vality.beholder.model.FormDataRequest; +import dev.vality.beholder.model.FormDataResponse; +import dev.vality.beholder.model.Region; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.client.HttpClientErrorException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringBootTest(properties = {"beholder.cron=-", + "selenium.regions=AL,AD,AR"}) +class BeholderServiceTest { + + @Autowired + BeholderService beholderService; + + @Autowired + List regions; + + @MockBean + PaymentsService paymentsService; + + @MockBean + SeleniumService seleniumService; + + @MockBean + MetricsService metricsService; + + private AutoCloseable mocks; + + private Object[] preparedMocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + preparedMocks = new Object[] {paymentsService, seleniumService, metricsService}; + } + + @AfterEach + void tearDown() throws Exception { + verifyNoMoreInteractions(preparedMocks); + mocks.close(); + } + + @Test + void behold() { + var firstRequest = new FormDataRequest(); + var secondRequest = new FormDataRequest(); + when(paymentsService.prepareFormData()) + .thenReturn(new FormDataRequest()) + .thenReturn(new FormDataRequest()) + .thenThrow(HttpClientErrorException.class); + + var firstResponse = FormDataResponse.builder().build(); + var secondResponse = FormDataResponse.builder().build(); + when(seleniumService.executePaymentRequest(firstRequest, regions.get(0))) + .thenReturn(firstResponse); + when(seleniumService.executePaymentRequest(secondRequest, regions.get(1))) + .thenReturn(secondResponse); + + beholderService.behold(); + + verify(paymentsService, times(3)).prepareFormData(); + verify(seleniumService, times(2)).executePaymentRequest(any(), any()); + verify(metricsService, times(1)).updateMetrics(List.of(firstResponse, secondResponse)); + } +} \ No newline at end of file diff --git a/src/test/java/dev/vality/beholder/testutil/ResponseUtil.java b/src/test/java/dev/vality/beholder/testutil/ResponseUtil.java new file mode 100644 index 0000000..78ba6d6 --- /dev/null +++ b/src/test/java/dev/vality/beholder/testutil/ResponseUtil.java @@ -0,0 +1,78 @@ +package dev.vality.beholder.testutil; + +import dev.vality.beholder.model.Browser; +import dev.vality.beholder.model.FormDataRequest; +import dev.vality.beholder.model.FormDataResponse; +import dev.vality.beholder.model.Region; +import dev.vality.swag.payments.model.*; +import lombok.experimental.UtilityClass; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@UtilityClass +public class ResponseUtil { + + public static Party getMyPartyResponse() { + Party party = new Party(); + party.setId(UUID.randomUUID().toString()); + party.setIsBlocked(false); + party.setIsBlocked(false); + return party; + } + + public static Shop getShopResponse(String id) { + Shop shop = new Shop(); + shop.setId(id); + shop.setCurrency("RUB"); + shop.setIsBlocked(false); + shop.setIsSuspended(false); + return shop; + } + + public static Claim getClaim() { + Claim claim = new Claim(); + claim.setId(0L); + claim.setStatus("success"); + return claim; + } + + public static InvoiceAndToken getInvoiceAndToken() { + InvoiceAndToken invoiceAndToken = new InvoiceAndToken(); + invoiceAndToken.setInvoice(new Invoice().id("2")); + invoiceAndToken.setInvoiceAccessToken(new AccessToken().payload("invoice_test_token")); + return invoiceAndToken; + } + + public static FormDataResponse getFormDataResponse() { + var region = new Region(); + region.setCode("AM"); + region.setCountry("Armenia"); + + var invoiceAndToken = getInvoiceAndToken(); + + FormDataRequest request = new FormDataRequest(); + request.setInvoiceAccessToken(invoiceAndToken.getInvoiceAccessToken().getPayload()); + request.setInvoiceId(invoiceAndToken.getInvoice().getId()); + + var requestStartAt = (double) Instant.now().minus(10, ChronoUnit.SECONDS).toEpochMilli(); + var responseStartAt = requestStartAt + 5; + var responseEndAt = responseStartAt + 5; + + FormDataResponse.FormPerformance performance = FormDataResponse.FormPerformance.builder() + .requestStartAt(requestStartAt) + .responseStartAt(responseStartAt) + .responseEndAt(responseEndAt).build(); + + return FormDataResponse.builder() + .region(region) + .browser(Browser.CHROME) + .request(request) + .networkLogs(List.of()) + .formPerformance(performance) + .build(); + } + +} diff --git a/src/test/java/dev/vality/beholder/testutil/SystemUtil.java b/src/test/java/dev/vality/beholder/testutil/SystemUtil.java new file mode 100644 index 0000000..3a84058 --- /dev/null +++ b/src/test/java/dev/vality/beholder/testutil/SystemUtil.java @@ -0,0 +1,21 @@ +package dev.vality.beholder.testutil; + +import lombok.experimental.UtilityClass; +import oshi.SystemInfo; +import oshi.hardware.CentralProcessor; +import oshi.hardware.HardwareAbstractionLayer; + +@UtilityClass +public class SystemUtil { + + private static final String ARM = "ARM"; + + public static boolean isArmArchitecture() { + SystemInfo si = new SystemInfo(); + HardwareAbstractionLayer hal = si.getHardware(); + CentralProcessor cpu = hal.getProcessor(); + var cpuId = cpu.getProcessorIdentifier(); + return cpuId.getVendor().toUpperCase().contains(ARM) + || cpuId.getMicroarchitecture().toUpperCase().contains(ARM); + } +} diff --git a/src/test/resources/dev/vality/beholder/converter/invalid_network_log.json b/src/test/resources/dev/vality/beholder/converter/invalid_network_log.json new file mode 100644 index 0000000..7a2bf90 --- /dev/null +++ b/src/test/resources/dev/vality/beholder/converter/invalid_network_log.json @@ -0,0 +1,12 @@ +{ + "msg": { + "method": "Network.loadingFinished", + "params": { + "encodedDataLength": 65958, + "requestId": "325.11", + "shouldReportCorbBlocking": false, + "timestamp": 30884.97731 + } + }, + "webview": "142FDA4010363970DE1DED0689086616" +} diff --git a/src/test/resources/dev/vality/beholder/converter/valid_finish_network_log.json b/src/test/resources/dev/vality/beholder/converter/valid_finish_network_log.json new file mode 100644 index 0000000..59d8502 --- /dev/null +++ b/src/test/resources/dev/vality/beholder/converter/valid_finish_network_log.json @@ -0,0 +1,12 @@ +{ + "message": { + "method": "Network.loadingFinished", + "params": { + "encodedDataLength": 65958, + "requestId": "325.11", + "shouldReportCorbBlocking": false, + "timestamp": 30884.97731 + } + }, + "webview": "142FDA4010363970DE1DED0689086616" +} diff --git a/src/test/resources/dev/vality/beholder/converter/valid_start_network_log.json b/src/test/resources/dev/vality/beholder/converter/valid_start_network_log.json new file mode 100644 index 0000000..0a4623a --- /dev/null +++ b/src/test/resources/dev/vality/beholder/converter/valid_start_network_log.json @@ -0,0 +1,37 @@ +{ + "message": { + "method": "Network.requestWillBeSent", + "params": { + "documentURL": "https://checkout.test.com/v1/checkout.html?test_token=sample_token", + "frameId": "142FDA4010363970DE1DED0689086616", + "hasUserGesture": false, + "initiator": { + "type": "parser", + "url": "https://checkout.test.com/v1/aeee2b0f52d9e4fffb4e.css" + }, + "loaderId": "A7EB9BE4EDAD0AFA88AB6E16CA26ECAD", + "redirectHasExtraInfo": false, + "request": { + "headers": { + "Origin": "https://checkout.test.com", + "Referer": "https://checkout.test.com/v1/aeee2b0f52d9e4fffb4e.css", + "User-Agent": "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36", + "sec-ch-ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"101\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Linux\"" + }, + "initialPriority": "VeryHigh", + "isSameSite": true, + "method": "GET", + "mixedContentType": "none", + "referrerPolicy": "strict-origin-when-cross-origin", + "url": "https://checkout.test.com/v1/fonts/90d16760.woff2" + }, + "requestId": "325.11", + "timestamp": 30884.700929, + "type": "Font", + "wallTime": 1653919988.713246 + } + }, + "webview": "142FDA4010363970DE1DED0689086616" +} \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..82148a3 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,10 @@ + + + + + + + + + +