From 507e14bc2ea7ed28eb2313c37d21aa4665bf22e1 Mon Sep 17 00:00:00 2001 From: spawn Date: Mon, 9 Mar 2026 01:40:48 +0300 Subject: [PATCH] Implement HW5 stubs, API helpers, optional SQL/MQ helpers, and test coverage --- README.md | 115 +++--- docker-compose.yml | 32 ++ pom.xml | 105 +++++- scripts/run-full-pipeline.ps1 | 96 +++++ selenoid/browsers.json | 12 + selenoid/video/.gitkeep | 1 + .../java/ru/kovbasa/config/DriverModule.java | 16 + .../ru/kovbasa/config/InjectorProvider.java | 16 + .../java/ru/kovbasa/config/TestConfig.java | 30 ++ .../kovbasa/driver/ChromeDriverFactory.java | 16 + .../java/ru/kovbasa/driver/DriverFactory.java | 7 + .../driver/SelectableDriverFactory.java | 23 ++ .../kovbasa/driver/SelenoidDriverFactory.java | 34 ++ .../ru/kovbasa/driver/WebDriverProvider.java | 37 ++ .../java/ru/kovbasa/elements/BaseElement.java | 27 ++ src/main/java/ru/kovbasa/elements/Button.java | 14 + .../java/ru/kovbasa/elements/CourseCard.java | 77 ++++ src/main/java/ru/kovbasa/elements/Link.java | 14 + .../java/ru/kovbasa/elements/UIElement.java | 7 + .../listeners/HighlightElementListener.java | 158 ++++++++ .../java/ru/kovbasa/pages/CatalogPage.java | 355 ++++++++++++++++++ .../java/ru/kovbasa/pages/CategoryPage.java | 24 ++ .../java/ru/kovbasa/pages/CourseItem.java | 6 + .../java/ru/kovbasa/pages/CourseLinkItem.java | 4 + .../java/ru/kovbasa/pages/CoursePage.java | 139 +++++++ src/main/java/ru/kovbasa/pages/MainPage.java | 70 ++++ .../ru/kovbasa/pages/PricedCourseItem.java | 4 + src/main/java/ru/kovbasa/utils/PageUtils.java | 22 ++ .../java/ru/otus/stub/helper/HttpHelper.java | 56 +++ .../java/ru/otus/stub/helper/MqHelper.java | 38 ++ .../java/ru/otus/stub/helper/SoapHelper.java | 48 +++ .../java/ru/otus/stub/helper/SqlHelper.java | 63 ++++ src/main/java/ru/otus/stub/model/Course.java | 4 + src/main/java/ru/otus/stub/model/User.java | 4 + .../java/ru/otus/stub/model/UserScore.java | 4 + .../java/ru/kovbasa/bdd/CucumberTest.java | 17 + src/test/java/ru/kovbasa/bdd/hooks/Hooks.java | 60 +++ .../ru/kovbasa/bdd/steps/CatalogSteps.java | 159 ++++++++ .../kovbasa/bdd/steps/StubFrontendSteps.java | 53 +++ .../otus/stub/tests/BaseWireMockStubTest.java | 92 +++++ .../ru/otus/stub/tests/HttpHelperTest.java | 45 +++ .../java/ru/otus/stub/tests/MqHelperTest.java | 23 ++ .../ru/otus/stub/tests/SoapHelperTest.java | 24 ++ .../ru/otus/stub/tests/SqlHelperTest.java | 31 ++ .../ru/otus/stub/tests/StubContractTest.java | 50 +++ .../ru/otus/stub/tests/StubSchemaTest.java | 42 +++ src/test/resources/features/catalog.feature | 29 ++ .../resources/features/stub_frontend.feature | 10 + src/test/resources/logback-test.xml | 15 + src/test/resources/logging.properties | 8 + .../resources/schemas/courses-schema.json | 14 + src/test/resources/schemas/score-schema.json | 10 + src/test/resources/schemas/users-schema.json | 16 + 53 files changed, 2320 insertions(+), 56 deletions(-) create mode 100644 docker-compose.yml create mode 100644 scripts/run-full-pipeline.ps1 create mode 100644 selenoid/browsers.json create mode 100644 selenoid/video/.gitkeep create mode 100644 src/main/java/ru/kovbasa/config/DriverModule.java create mode 100644 src/main/java/ru/kovbasa/config/InjectorProvider.java create mode 100644 src/main/java/ru/kovbasa/config/TestConfig.java create mode 100644 src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java create mode 100644 src/main/java/ru/kovbasa/driver/DriverFactory.java create mode 100644 src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java create mode 100644 src/main/java/ru/kovbasa/driver/SelenoidDriverFactory.java create mode 100644 src/main/java/ru/kovbasa/driver/WebDriverProvider.java create mode 100644 src/main/java/ru/kovbasa/elements/BaseElement.java create mode 100644 src/main/java/ru/kovbasa/elements/Button.java create mode 100644 src/main/java/ru/kovbasa/elements/CourseCard.java create mode 100644 src/main/java/ru/kovbasa/elements/Link.java create mode 100644 src/main/java/ru/kovbasa/elements/UIElement.java create mode 100644 src/main/java/ru/kovbasa/listeners/HighlightElementListener.java create mode 100644 src/main/java/ru/kovbasa/pages/CatalogPage.java create mode 100644 src/main/java/ru/kovbasa/pages/CategoryPage.java create mode 100644 src/main/java/ru/kovbasa/pages/CourseItem.java create mode 100644 src/main/java/ru/kovbasa/pages/CourseLinkItem.java create mode 100644 src/main/java/ru/kovbasa/pages/CoursePage.java create mode 100644 src/main/java/ru/kovbasa/pages/MainPage.java create mode 100644 src/main/java/ru/kovbasa/pages/PricedCourseItem.java create mode 100644 src/main/java/ru/kovbasa/utils/PageUtils.java create mode 100644 src/main/java/ru/otus/stub/helper/HttpHelper.java create mode 100644 src/main/java/ru/otus/stub/helper/MqHelper.java create mode 100644 src/main/java/ru/otus/stub/helper/SoapHelper.java create mode 100644 src/main/java/ru/otus/stub/helper/SqlHelper.java create mode 100644 src/main/java/ru/otus/stub/model/Course.java create mode 100644 src/main/java/ru/otus/stub/model/User.java create mode 100644 src/main/java/ru/otus/stub/model/UserScore.java create mode 100644 src/test/java/ru/kovbasa/bdd/CucumberTest.java create mode 100644 src/test/java/ru/kovbasa/bdd/hooks/Hooks.java create mode 100644 src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java create mode 100644 src/test/java/ru/kovbasa/bdd/steps/StubFrontendSteps.java create mode 100644 src/test/java/ru/otus/stub/tests/BaseWireMockStubTest.java create mode 100644 src/test/java/ru/otus/stub/tests/HttpHelperTest.java create mode 100644 src/test/java/ru/otus/stub/tests/MqHelperTest.java create mode 100644 src/test/java/ru/otus/stub/tests/SoapHelperTest.java create mode 100644 src/test/java/ru/otus/stub/tests/SqlHelperTest.java create mode 100644 src/test/java/ru/otus/stub/tests/StubContractTest.java create mode 100644 src/test/java/ru/otus/stub/tests/StubSchemaTest.java create mode 100644 src/test/resources/features/catalog.feature create mode 100644 src/test/resources/features/stub_frontend.feature create mode 100644 src/test/resources/logback-test.xml create mode 100644 src/test/resources/logging.properties create mode 100644 src/test/resources/schemas/courses-schema.json create mode 100644 src/test/resources/schemas/score-schema.json create mode 100644 src/test/resources/schemas/users-schema.json diff --git a/README.md b/README.md index ed2f97d..aa7e529 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,78 @@ -# OTUS Rest-assured Homework 3 +# OTUS Homework 5: Stub & API Testing (API-helpers) +В этом проекте реализован stub-сервер на WireMock, API-helpers для HTTP/SOAP и автотесты контрактов/схем, плюс UI-проверка stub frontend через Cucumber. -## Цель проекта -Автоматизировать API-сценарии для `https://petstore.swagger.io` с использованием Rest-assured, JUnit Jupiter и quality gates (Checkstyle + SpotBugs). +## Что реализовано +- Stub endpoints на WireMock: + - `/user/get/{id}` - получение оценки пользователя + - `/cource/get/all` - получение списка курсов + - `/user/get/all` - получение списка пользователей +- Контракты и схемы: + - контрактные тесты для users/courses/score + - JSON schema тесты для users/courses/score +- API-helpers: + - `HttpHelper` + - `SoapHelper` +- Дополнительно (optional): + - `SqlHelper` + - `MqHelper` +- Stub frontend: + - endpoint `/frontend` + - Cucumber-сценарий проверки frontend +- Selenoid support: + - запуск UI-тестов через `-Dbrowser=selenoid` -## Стек технологий -- Java 21 -- Maven -- Rest-assured `6.0.0` -- JUnit Jupiter `6.0.2` -- Jackson Databind `2.21.0` -- Commons Codec `1.21.0` -- Maven Surefire Plugin `3.5.4` -- Maven Compiler Plugin `3.15.0` -- Checkstyle (`maven-checkstyle-plugin` `3.6.0`) -- SpotBugs (`spotbugs-maven-plugin` `4.9.8.2`) +## Что проверяется в Cucumber +- Для HW5 в раннер подключен только `features/stub_frontend.feature`. +- `features/catalog.feature` и `CatalogSteps` сохранены в проекте, но в текущий раннер HW5 не входят. -## Реализованные сценарии -1. `POST /pet` + `GET /pet/{id}`: - - создание питомца; - - повторное чтение по id; - - проверка, что вернулся именно созданный объект. - -2. `GET /pet/{id}`: - - проверка схемы ответа через Json Schema Validator. - -3. Негативные сценарии: - - `POST /pet` с malformed JSON возвращает `4xx`; - - `GET /pet/{id}` для удаленного/несуществующего id возвращает `404`; - - `GET /pet/findByStatus` с невалидным статусом возвращает пустой список. - -## Архитектура -- 2-уровневый дизайн: - - `service` слой (HTTP-вызовы + спецификации); - - `tests` слой (сценарии). -- Тесты создают сервис напрямую (`new PetService()`), без DI-фреймворка. - -## Структура проекта -- `src/main/java/ru/otus/petstore/config` — конфигурация -- `src/main/java/ru/otus/petstore/model` — модели API (`Pet`, `Category`, `Tag`) -- `src/main/java/ru/otus/petstore/service` — сервисы API -- `src/test/java/ru/otus/petstore/tests` — автотесты (позитивные и негативные сценарии) -- `src/test/java/ru/otus/petstore/util` — фабрика тестовых данных -- `src/test/resources/schemas` — JSON schema - -## Запуск -### 1. Только тесты +## Команды запуска +Основной прогон (API + stub + helpers): ```bash mvn test ``` -### 2. Полная проверка (тесты + Checkstyle + SpotBugs) +Только ключевые тесты HW5: +```bash +mvn "-Dtest=HttpHelperTest,SoapHelperTest,SqlHelperTest,MqHelperTest,StubContractTest,StubSchemaTest" test +``` + +UI Cucumber (stub frontend): +```bash +mvn -Pui-tests test +``` + +UI Cucumber через Selenoid: +```bash +mvn -Pui-tests "-Dbrowser=selenoid" "-Dselenoid.url=http://localhost:4444/wd/hub" test +``` + +Полная проверка качества: ```bash mvn verify ``` -## Параметры запуска -- `base.uri` (по умолчанию `https://petstore.swagger.io`) -- `base.path` (по умолчанию `/v2`) -- значения читаются из `System properties` во время выполнения тестов. +## Docker/Selenoid +Если UI-тесты через Selenoid не нужны, этот раздел можно пропустить. -Пример: +Предварительно: +- Установить Docker Desktop. +- Убедиться, что Docker запущен. + +Поднять инфраструктуру: ```bash -mvn "-Dbase.uri=https://petstore.swagger.io" "-Dbase.path=/v2" test +docker compose up -d +``` + +Проверка, что Selenoid поднялся: +- Selenoid API: `http://localhost:4444/wd/hub` +- Selenoid UI: `http://localhost:8081` + +После этого можно запускать UI-тесты через Selenoid: +```bash +mvn -Pui-tests "-Dbrowser=selenoid" "-Dselenoid.url=http://localhost:4444/wd/hub" test +``` + +Или выполнить полный пайплайн одной командой (поднимет Docker/Selenoid и запустит все проверки): +```bash +powershell -ExecutionPolicy Bypass -File .\scripts\run-full-pipeline.ps1 ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..48080ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +name: hw3 + +services: + selenoid: + image: aerokube/selenoid:latest-release + environment: + DOCKER_API_VERSION: "1.44" + ports: + - "4444:4444" + volumes: + - "./selenoid/browsers.json:/etc/selenoid/browsers.json:ro" + - "/var/run/docker.sock:/var/run/docker.sock" + - "./selenoid/video:/opt/selenoid/video" + command: + - "-container-network" + - "hw3_default" + - "-limit" + - "4" + - "-timeout" + - "5m" + - "-video-output-dir" + - "/opt/selenoid/video" + + selenoid-ui: + image: aerokube/selenoid-ui:latest-release + depends_on: + - selenoid + command: + - "--selenoid-uri" + - "http://selenoid:4444" + ports: + - "8081:8080" diff --git a/pom.xml b/pom.xml index f25e8a7..dc44b37 100644 --- a/pom.xml +++ b/pom.xml @@ -16,10 +16,23 @@ UTF-8 https://petstore.swagger.io /v2 + http://localhost:8089 + Python Developer + chrome + http://localhost:4444/wd/hub 6.0.0 - 6.0.2 + 5.14.2 + 1.14.2 2.21.0 + 4.40.0 + 7.34.2 + 7.0.0 + 33.5.0-jre + 1.22.1 + 2.0.17 + 1.5.31 + 2.35.2 3.15.0 3.5.4 @@ -66,14 +79,69 @@ org.junit.jupiter - junit-jupiter-api + junit-jupiter ${junit.jupiter.version} test - org.junit.jupiter - junit-jupiter-engine - ${junit.jupiter.version} + org.junit.platform + junit-platform-suite + ${junit.platform.suite.version} + test + + + + org.seleniumhq.selenium + selenium-java + ${selenium.version} + + + io.cucumber + cucumber-java + ${cucumber.version} + test + + + io.cucumber + cucumber-junit-platform-engine + ${cucumber.version} + test + + + com.google.inject + guice + ${guice.version} + + + com.google.guava + guava + ${guava.version} + + + org.jsoup + jsoup + ${jsoup.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + com.github.tomakehurst + wiremock-jre8 + ${wiremock.version} + + + com.h2database + h2 + 2.3.232 test @@ -97,9 +165,15 @@ ${surefire.version} false + + **/CucumberTest.java + ${base.uri} ${base.path} + ${course.name} + ${browser} + ${selenoid.url} @@ -147,6 +221,27 @@ spotbugs-exclude.xml + + + + + ui-tests + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + **/CucumberTest.java + + + + + + + diff --git a/scripts/run-full-pipeline.ps1 b/scripts/run-full-pipeline.ps1 new file mode 100644 index 0000000..474b97c --- /dev/null +++ b/scripts/run-full-pipeline.ps1 @@ -0,0 +1,96 @@ +$ErrorActionPreference = "Stop" + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Command + ) + + Write-Host "" + Write-Host "=== $Name ===" -ForegroundColor Cyan + Write-Host $Command + Invoke-Expression $Command + if ($LASTEXITCODE -ne 0) { + throw "Step '$Name' failed with exit code $LASTEXITCODE" + } +} + +function Start-DockerDesktopIfNeeded { + function Test-DockerDaemonReady { + cmd /c "docker info >nul 2>nul" + return ($LASTEXITCODE -eq 0) + } + + $dockerInfoOk = Test-DockerDaemonReady + + if ($dockerInfoOk) { + return + } + + $candidatePaths = @( + "$Env:ProgramFiles\Docker\Docker\Docker Desktop.exe", + "$Env:ProgramFiles(x86)\Docker\Docker\Docker Desktop.exe" + ) + + $dockerExe = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $dockerExe) { + throw "Docker Desktop not found. Install Docker Desktop or start Docker manually." + } + + # Try to start Docker Desktop even if process exists; daemon may be down. + Write-Host "Ensuring Docker Desktop is started..." -ForegroundColor Yellow + Start-Process -FilePath $dockerExe | Out-Null + + # If the process still does not appear, fail fast. + $dockerProcess = Get-Process -Name "Docker Desktop" -ErrorAction SilentlyContinue + if (-not $dockerProcess) { + $dockerProcess = Get-Process -Name "Docker Desktop.exe" -ErrorAction SilentlyContinue + } + if (-not $dockerProcess) { + Write-Host "Docker Desktop process is not detected yet; waiting for daemon..." -ForegroundColor Yellow + } + + $deadline = (Get-Date).AddMinutes(3) + while ((Get-Date) -lt $deadline) { + if (Test-DockerDaemonReady) { + return + } + Start-Sleep -Seconds 3 + } + + throw "Docker daemon is not available after 3 minutes." +} + +function Wait-Selenoid { + param( + [Parameter(Mandatory = $true)][string]$StatusUrl + ) + + $deadline = (Get-Date).AddMinutes(2) + while ((Get-Date) -lt $deadline) { + try { + $response = Invoke-RestMethod -Uri $StatusUrl -Method Get -TimeoutSec 5 + if ($null -ne $response.total -and $response.total -ge 1) { + return + } + } catch { + Start-Sleep -Seconds 2 + continue + } + Start-Sleep -Seconds 2 + } + + throw "Selenoid is not ready at $StatusUrl" +} + +Write-Host "Preparing Docker/Selenoid..." -ForegroundColor Green +Start-DockerDesktopIfNeeded +Invoke-Step -Name "Docker Compose Up" -Command "docker compose up -d --wait" +Wait-Selenoid -StatusUrl "http://localhost:4444/status" + +Invoke-Step -Name "Quality Gates" -Command "mvn -DskipTests verify" +Invoke-Step -Name "API and Stub Tests" -Command "mvn test" +Invoke-Step -Name "UI Tests via Selenoid" -Command "mvn -Pui-tests ""-Dbrowser=selenoid"" ""-Dselenoid.url=http://localhost:4444/wd/hub"" test" + +Write-Host "" +Write-Host "Full pipeline completed successfully." -ForegroundColor Green diff --git a/selenoid/browsers.json b/selenoid/browsers.json new file mode 100644 index 0000000..d9ad746 --- /dev/null +++ b/selenoid/browsers.json @@ -0,0 +1,12 @@ +{ + "chrome": { + "default": "126.0", + "versions": { + "126.0": { + "image": "selenoid/vnc_chrome:126.0", + "port": "4444", + "path": "/" + } + } + } +} diff --git a/selenoid/video/.gitkeep b/selenoid/video/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/selenoid/video/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/java/ru/kovbasa/config/DriverModule.java b/src/main/java/ru/kovbasa/config/DriverModule.java new file mode 100644 index 0000000..a6cad54 --- /dev/null +++ b/src/main/java/ru/kovbasa/config/DriverModule.java @@ -0,0 +1,16 @@ +package ru.kovbasa.config; + +import com.google.inject.AbstractModule; +import com.google.inject.Singleton; +import ru.kovbasa.driver.DriverFactory; +import ru.kovbasa.driver.SelectableDriverFactory; +import ru.kovbasa.driver.WebDriverProvider; + +public class DriverModule extends AbstractModule { + + @Override + protected void configure() { + bind(DriverFactory.class).to(SelectableDriverFactory.class).in(Singleton.class); + bind(WebDriverProvider.class).in(Singleton.class); + } +} diff --git a/src/main/java/ru/kovbasa/config/InjectorProvider.java b/src/main/java/ru/kovbasa/config/InjectorProvider.java new file mode 100644 index 0000000..d9456bd --- /dev/null +++ b/src/main/java/ru/kovbasa/config/InjectorProvider.java @@ -0,0 +1,16 @@ +package ru.kovbasa.config; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +public final class InjectorProvider { + + private static final Injector INJECTOR = Guice.createInjector(new DriverModule()); + + private InjectorProvider() { + } + + public static Injector getInjector() { + return INJECTOR; + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/config/TestConfig.java b/src/main/java/ru/kovbasa/config/TestConfig.java new file mode 100644 index 0000000..d4c023b --- /dev/null +++ b/src/main/java/ru/kovbasa/config/TestConfig.java @@ -0,0 +1,30 @@ +package ru.kovbasa.config; + +public final class TestConfig { + + private TestConfig() { + } + + public static String getBaseUrl() { + final String configuredBaseUrl = System.getProperty("base.url"); + if (configuredBaseUrl != null && !configuredBaseUrl.isBlank()) { + return configuredBaseUrl; + } + if ("selenoid".equalsIgnoreCase(getBrowser())) { + return "http://host.docker.internal:8089"; + } + return "http://localhost:8089"; + } + + public static String getCourseName() { + return System.getProperty("course.name", "Python Developer"); + } + + public static String getBrowser() { + return System.getProperty("browser", "chrome"); + } + + public static String getSelenoidUrl() { + return System.getProperty("selenoid.url", "http://localhost:4444/wd/hub"); + } +} diff --git a/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java b/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java new file mode 100644 index 0000000..1c90404 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java @@ -0,0 +1,16 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; + +public class ChromeDriverFactory implements DriverFactory { + + @Override + public WebDriver createDriver() { + final ChromeOptions options = new ChromeOptions(); + options.addArguments("--start-maximized"); + options.addArguments("--disable-notifications"); + return new ChromeDriver(options); + } +} diff --git a/src/main/java/ru/kovbasa/driver/DriverFactory.java b/src/main/java/ru/kovbasa/driver/DriverFactory.java new file mode 100644 index 0000000..fccc68d --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/DriverFactory.java @@ -0,0 +1,7 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; + +public interface DriverFactory { + WebDriver createDriver(); +} diff --git a/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java b/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java new file mode 100644 index 0000000..77cbd50 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java @@ -0,0 +1,23 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import ru.kovbasa.config.TestConfig; + +public class SelectableDriverFactory implements DriverFactory { + + private final DriverFactory chromeFactory = new ChromeDriverFactory(); + private final DriverFactory selenoidFactory = new SelenoidDriverFactory(); + + @Override + public WebDriver createDriver() { + final String browser = TestConfig.getBrowser().toLowerCase(); + if ("chrome".equals(browser)) { + return chromeFactory.createDriver(); + } + if ("selenoid".equals(browser)) { + return selenoidFactory.createDriver(); + } + throw new IllegalArgumentException( + "Unsupported browser: " + browser + ". Supported browsers: chrome, selenoid"); + } +} diff --git a/src/main/java/ru/kovbasa/driver/SelenoidDriverFactory.java b/src/main/java/ru/kovbasa/driver/SelenoidDriverFactory.java new file mode 100644 index 0000000..35cb4f2 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/SelenoidDriverFactory.java @@ -0,0 +1,34 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import ru.kovbasa.config.TestConfig; + +import java.net.MalformedURLException; +import java.net.URI; + +public class SelenoidDriverFactory implements DriverFactory { + + @Override + public WebDriver createDriver() { + final ChromeOptions options = new ChromeOptions(); + options.setCapability("browserName", "chrome"); + options.setCapability("browserVersion", "126.0"); + options.setCapability("selenoid:options", java.util.Map.of( + "enableVNC", true, + "enableVideo", false + )); + try { + return new RemoteWebDriver(URI.create(TestConfig.getSelenoidUrl()).toURL(), options); + } catch (MalformedURLException exception) { + throw new IllegalArgumentException("Invalid selenoid.url: " + TestConfig.getSelenoidUrl(), exception); + } catch (RuntimeException exception) { + throw new IllegalStateException( + "Cannot create RemoteWebDriver session via selenoid.url=" + TestConfig.getSelenoidUrl() + + ". Ensure Docker is running and execute 'docker compose up -d' first.", + exception + ); + } + } +} diff --git a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java new file mode 100644 index 0000000..652e3ae --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java @@ -0,0 +1,37 @@ +package ru.kovbasa.driver; + +import com.google.inject.Inject; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.support.events.EventFiringDecorator; +import ru.kovbasa.listeners.HighlightElementListener; + +public final class WebDriverProvider { + + private WebDriver driver; + private final DriverFactory driverFactory; + + @Inject + public WebDriverProvider(DriverFactory driverFactory) { + this.driverFactory = driverFactory; + } + + public WebDriver getDriver() { + if (driver == null) { + driver = createDecoratedDriver(); + } + return driver; + } + + private WebDriver createDecoratedDriver() { + final WebDriver raw = driverFactory.createDriver(); + return new EventFiringDecorator(new HighlightElementListener()) + .decorate(raw); + } + + public void quit() { + if (driver != null) { + driver.quit(); + driver = null; + } + } +} diff --git a/src/main/java/ru/kovbasa/elements/BaseElement.java b/src/main/java/ru/kovbasa/elements/BaseElement.java new file mode 100644 index 0000000..9f9b833 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/BaseElement.java @@ -0,0 +1,27 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public abstract class BaseElement implements UIElement { + + protected final WebElement element; + + protected BaseElement(WebElement element) { + this.element = element; + } + + @Override + public void click() { + element.click(); + } + + @Override + public String getText() { + return element.getText(); + } + + @Override + public boolean isDisplayed() { + return element.isDisplayed(); + } +} diff --git a/src/main/java/ru/kovbasa/elements/Button.java b/src/main/java/ru/kovbasa/elements/Button.java new file mode 100644 index 0000000..f784835 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/Button.java @@ -0,0 +1,14 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public class Button extends BaseElement { + + public Button(WebElement element) { + super(element); + } + + public boolean isEnabled() { + return element.isEnabled(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/elements/CourseCard.java b/src/main/java/ru/kovbasa/elements/CourseCard.java new file mode 100644 index 0000000..a4be7c7 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/CourseCard.java @@ -0,0 +1,77 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.time.LocalDate; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CourseCard extends BaseElement { + + private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1"); + private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1"); + private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s]*)\\s*[₽р]"); + + public CourseCard(WebElement element) { + super(element); + } + + public String title() { + return element.findElement(titleLocator).getText().trim(); + } + + public LocalDate startDate() { + String raw = element.findElement(dateLocator).getText().trim(); + if (raw.isEmpty()) { + throw new RuntimeException("Пустая дата на карточке курса: " + title()); + } + + String[] parts = raw.split("·"); + String datePart = parts[0].trim(); + + if (datePart.startsWith("С ")) { + datePart = datePart.substring(2).trim(); + } + + datePart = datePart.replace(",", "").replace("года", "").trim(); + + boolean hasYear = datePart.matches(".*\\d{4}.*"); + + String normalized; + DateTimeFormatter formatter; + + if (hasYear) { + normalized = datePart; + formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru")); + } else { + normalized = datePart + " " + Year.now().getValue(); + formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru")); + } + + return LocalDate.parse(normalized, formatter); + } + + public int price() { + final String text = element.getText(); + final Matcher matcher = PRICE_PATTERN.matcher(text); + + int maxPrice = -1; + while (matcher.find()) { + final String raw = matcher.group(1).replace(" ", ""); + final int parsed = Integer.parseInt(raw); + if (parsed > maxPrice) { + maxPrice = parsed; + } + } + + if (maxPrice < 0) { + throw new RuntimeException("Цена курса не найдена в карточке: " + title()); + } + + return maxPrice; + } +} diff --git a/src/main/java/ru/kovbasa/elements/Link.java b/src/main/java/ru/kovbasa/elements/Link.java new file mode 100644 index 0000000..010d46b --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/Link.java @@ -0,0 +1,14 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public class Link extends BaseElement { + + public Link(WebElement element) { + super(element); + } + + public String getHref() { + return element.getAttribute("href"); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/elements/UIElement.java b/src/main/java/ru/kovbasa/elements/UIElement.java new file mode 100644 index 0000000..b7a7e41 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/UIElement.java @@ -0,0 +1,7 @@ +package ru.kovbasa.elements; + +public interface UIElement { + void click(); + String getText(); + boolean isDisplayed(); +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java new file mode 100644 index 0000000..4512f37 --- /dev/null +++ b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java @@ -0,0 +1,158 @@ +package ru.kovbasa.listeners; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.WrapsDriver; +import org.openqa.selenium.support.events.WebDriverListener; + +public class HighlightElementListener implements WebDriverListener { + private static final Logger LOG = LoggerFactory.getLogger(HighlightElementListener.class); + + private static final String HIGHLIGHT_STYLE = + "outline: 4px solid red !important; " + + "box-shadow: 0 0 0 4px rgba(255,0,0,0.20) !important; " + + "background: rgba(255,0,0,0.06) !important;"; + private WebElement highlightedElement; + private String highlightedOriginalStyle; + + @Override + public void beforeClick(WebElement element) { + try { + clearHighlight(); + final WebElement tile = findTileContainer(element); + applyHighlight(tile); + pauseForVisibility(); + } catch (RuntimeException e) { + LOG.debug("beforeClick highlight skipped", e); + } + } + + @Override + public void afterClick(WebElement element) { + clearHighlight(); + } + + private void applyHighlight(WebElement element) { + if (element == null) { + return; + } + + try { + final WebDriver raw = ((WrapsDriver) element).getWrappedDriver(); + if (!(raw instanceof JavascriptExecutor)) { + return; + } + + final JavascriptExecutor js = (JavascriptExecutor) raw; + + String original = safeGetAttr(element, "style"); + if (original == null) { + original = ""; + } + + this.highlightedElement = element; + this.highlightedOriginalStyle = original; + + js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);", + element, original, HIGHLIGHT_STYLE); + } catch (StaleElementReferenceException ignored) { + } catch (RuntimeException e) { + LOG.debug("applyHighlight skipped", e); + } + } + + private void clearHighlight() { + if (highlightedElement == null) { + return; + } + + try { + final WebDriver raw = ((WrapsDriver) highlightedElement).getWrappedDriver(); + if (!(raw instanceof JavascriptExecutor)) { + highlightedElement = null; + highlightedOriginalStyle = null; + return; + } + + final JavascriptExecutor js = (JavascriptExecutor) raw; + final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle; + js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original); + } catch (StaleElementReferenceException ignored) { + } catch (RuntimeException e) { + LOG.debug("clearHighlight skipped", e); + } finally { + highlightedElement = null; + highlightedOriginalStyle = null; + } + } + + private WebElement findTileContainer(WebElement element) { + try { + final String tag = safeGetTag(element); + final String href = safeGetAttr(element, "href"); + final String cls = safeGetAttr(element, "class"); + + if ("a".equalsIgnoreCase(tag) && href != null && + (href.contains("/lessons/") || href.contains("/categories/"))) { + return element; + } + if (cls != null && cls.contains("sc-zzdkm7-0")) { + return element; + } + + final WebDriver rawDriver = ((WrapsDriver) element).getWrappedDriver(); + if (!(rawDriver instanceof JavascriptExecutor)) { + return element; + } + + final JavascriptExecutor js = (JavascriptExecutor) rawDriver; + + WebElement current = element; + for (int i = 0; i < 8; i++) { + final Object parentObj = js.executeScript("return arguments[0].parentElement;", current); + if (!(parentObj instanceof WebElement)) { + break; + } + + current = (WebElement) parentObj; + + final String t = safeGetTag(current); + final String h = safeGetAttr(current, "href"); + final String c = safeGetAttr(current, "class"); + + if ("a".equalsIgnoreCase(t) && h != null && + (h.contains("/lessons/") || h.contains("/categories/"))) { + return current; + } + if (c != null && c.contains("sc-zzdkm7-0")) { + return current; + } + } + } catch (RuntimeException e) { + LOG.debug("findTileContainer fallback to original element", e); + } + return element; + } + + private String safeGetAttr(WebElement el, String name) { + try { return el.getAttribute(name); } + catch (Exception e) { return null; } + } + + private String safeGetTag(WebElement el) { + try { return el.getTagName(); } + catch (Exception e) { return null; } + } + + private void pauseForVisibility() { + try { + Thread.sleep(120); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/ru/kovbasa/pages/CatalogPage.java b/src/main/java/ru/kovbasa/pages/CatalogPage.java new file mode 100644 index 0000000..27889be --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CatalogPage.java @@ -0,0 +1,355 @@ +package ru.kovbasa.pages; + +import com.google.inject.Inject; +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.ExpectedCondition; +import org.openqa.selenium.support.ui.WebDriverWait; + +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.driver.WebDriverProvider; +import ru.kovbasa.elements.CourseCard; +import ru.kovbasa.utils.PageUtils; + +public class CatalogPage { + + private final WebDriver driver; + + private final By courseCards = By.cssSelector("a.sc-zzdkm7-0"); + private final By courseLinks = By.cssSelector("a[href*='/lessons/'], a[href*='/online/']"); + private final By learningMenu = By.cssSelector("span[title='Обучение']"); + private final By prepCoursesLink = By.xpath( + "//a[contains(normalize-space(),'Подготовительные курсы')]"); + private final By showMoreButton = By.xpath("//button[contains(normalize-space(),'Показать еще')]"); + + @Inject + public CatalogPage(WebDriverProvider provider) { + this.driver = provider.getDriver(); + } + + public CatalogPage open() { + driver.get(TestConfig.getBaseUrl() + "/catalog/courses"); + return this; + } + + public WebElement findCourseByName(String name) { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final List links = driver.findElements(courseLinks); + return links.stream() + .filter(e -> e.getText().toLowerCase().contains(name.toLowerCase())) + .findFirst() + .orElseThrow(() -> new NoSuchElementException( + "Course not found in catalog by name: " + name)); + } + + public List findCoursesByName(String name) { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + return driver.findElements(courseLinks).stream() + .filter(e -> e.getText().toLowerCase().contains(name.toLowerCase())) + .toList(); + } + + public CoursePage clickCourseByName(String name) { + PageUtils.removeBottomBanner(driver); + + final WebElement course = findCourseByName(name); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", course); + + new Actions(driver) + .moveToElement(course) + .perform(); + course.click(); + + return new CoursePage(driver); + } + + public CoursePage clickRandomCourseByName(String name) { + PageUtils.removeBottomBanner(driver); + + final List courses = findCoursesByName(name); + if (courses.isEmpty()) { + throw new NoSuchElementException("Course not found in catalog by name: " + name); + } + + final WebElement chosen = courses.get(ThreadLocalRandom.current().nextInt(courses.size())); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", chosen); + + new Actions(driver) + .moveToElement(chosen) + .perform(); + chosen.click(); + + return new CoursePage(driver); + } + + public List getAllCourseCards() { + return driver.findElements(courseCards).stream() + .map(CourseCard::new) + .toList(); + } + + public List getAllCourses() { + return getAllCourseCards().stream() + .map(card -> { + try { + return new CourseItem(card.title(), card.startDate()); + } catch (RuntimeException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + public List findEarliestCourses() { + final List all = getAllCourses(); + + final LocalDate minDate = all.stream() + .map(CourseItem::startDate) + .reduce((left, right) -> left.isBefore(right) ? left : right) + .orElseThrow(); + + return all.stream() + .filter(c -> c.startDate().isEqual(minDate)) + .sorted(Comparator.comparing(CourseItem::title)) + .toList(); + } + + public List findLatestCourses() { + final List all = getAllCourses(); + + final LocalDate maxDate = all.stream() + .map(CourseItem::startDate) + .reduce((left, right) -> left.isAfter(right) ? left : right) + .orElseThrow(); + + return all.stream() + .filter(c -> c.startDate().isEqual(maxDate)) + .sorted(Comparator.comparing(CourseItem::title)) + .toList(); + } + + public List findCoursesStartingFrom(LocalDate dateFrom) { + return getAllCourses().stream() + .filter(course -> !course.startDate().isBefore(dateFrom)) + .sorted(Comparator.comparing(CourseItem::startDate).thenComparing(CourseItem::title)) + .toList(); + } + + public CatalogPage openPreparatoryCourses() { + driver.get(TestConfig.getBaseUrl()); + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + final WebElement menu = wait.until(drv -> drv.findElement(learningMenu)); + menu.click(); + + final WebElement prep = wait.until(drv -> drv.findElements(prepCoursesLink).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", prep); + prep.click(); + + wait.until(drv -> drv.findElements( + By.xpath("//*[contains(normalize-space(),'Подготовительные курсы')]") + ).stream().anyMatch(WebElement::isDisplayed)); + + expandAllCourses(); + return this; + } + + public List getAllCoursesWithPrice() { + return getAllCourseCards().stream() + .map(card -> { + try { + return new PricedCourseItem(card.title(), card.price()); + } catch (RuntimeException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + public List getPreparatoryCourseLinks() { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final Map titleByUrl = new LinkedHashMap<>(); + for (WebElement link : driver.findElements(courseLinks)) { + if (!link.isDisplayed()) { + continue; + } + final String title = link.getText().trim(); + final String url = link.getAttribute("href"); + if (title.isEmpty() || url == null || !isCourseUrl(url)) { + continue; + } + titleByUrl.putIfAbsent(url, title); + } + + final List> onlineEntries = titleByUrl.entrySet().stream() + .filter(entry -> entry.getKey().contains("/online/")) + .toList(); + + final List items = new ArrayList<>(); + if (!onlineEntries.isEmpty()) { + onlineEntries.forEach(entry -> items.add(new CourseLinkItem(cleanTitle(entry.getValue()), entry.getKey()))); + return items; + } + + titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(cleanTitle(title), url))); + return items; + } + + public List getPreparatoryCoursesWithDiscountedFullPrice() { + final List links = getPreparatoryCourseLinks(); + return links.stream() + .map(link -> { + driver.get(link.url()); + final CoursePage coursePage = new CoursePage(driver); + final int price = coursePage.getPriceForComparison(); + String title = link.title(); + try { + title = coursePage.getCourseTitle(); + } catch (RuntimeException ignored) { } + return new PricedCourseItem(title, price); + }) + .toList(); + } + + public List getCatalogLessonCourseLinks() { + open(); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final Map titleByUrl = new LinkedHashMap<>(); + for (WebElement link : driver.findElements(courseLinks)) { + if (!link.isDisplayed()) { + continue; + } + final String title = link.getText().trim(); + final String url = link.getAttribute("href"); + if (title.isEmpty() || url == null || !url.contains("/lessons/")) { + continue; + } + titleByUrl.putIfAbsent(url, cleanTitle(title)); + } + + final List items = new ArrayList<>(); + titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(title, url))); + return items; + } + + public List getCatalogCoursesWithDiscountedFullPrice() { + final List links = getCatalogLessonCourseLinks(); + return links.stream() + .map(link -> { + driver.get(link.url()); + final CoursePage coursePage = new CoursePage(driver); + final int price = coursePage.getPriceForComparison(); + String title = link.title(); + try { + title = coursePage.getCourseTitle(); + } catch (RuntimeException ignored) { } + return new PricedCourseItem(title, price); + }) + .toList(); + } + + private void expandAllCourses() { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5)); + + while (true) { + try { + final WebElement button = wait.until(drv -> drv.findElements(showMoreButton).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", button); + button.click(); + wait.until(drv -> !drv.findElements(courseLinks).isEmpty()); + } catch (RuntimeException ignored) { + break; + } + } + } + + private boolean isCourseUrl(String url) { + return url.contains("/lessons/") || url.contains("/online/"); + } + + private String cleanTitle(String rawTitle) { + final String[] lines = rawTitle.split("\\R"); + for (int i = lines.length - 1; i >= 0; i--) { + final String line = lines[i].trim(); + if (!line.isEmpty()) { + return line; + } + } + return rawTitle.trim(); + } + + public PricedCourseItem findMostExpensiveCourse() { + return getAllCoursesWithPrice().stream() + .reduce((left, right) -> left.price() >= right.price() ? left : right) + .orElseThrow(() -> new NoSuchElementException("Priced course cards are not found")); + } + + public PricedCourseItem findCheapestCourse() { + return getAllCoursesWithPrice().stream() + .reduce((left, right) -> left.price() <= right.price() ? left : right) + .orElseThrow(() -> new NoSuchElementException("Priced course cards are not found")); + } + + public CoursePage openCourse(CourseItem course) { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + + final WebElement card = wait.until(drv -> + drv.findElements(courseCards).stream() + .filter(c -> new CourseCard(c).title().equals(course.title())) + .findFirst() + .orElse(null) + ); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", card); + + new Actions(driver) + .moveToElement(card) + .perform(); + card.click(); + + return new CoursePage(driver); + } +} diff --git a/src/main/java/ru/kovbasa/pages/CategoryPage.java b/src/main/java/ru/kovbasa/pages/CategoryPage.java new file mode 100644 index 0000000..1271ee5 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CategoryPage.java @@ -0,0 +1,24 @@ +package ru.kovbasa.pages; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.openqa.selenium.WebDriver; + +public class CategoryPage { + + private final WebDriver driver; + + public CategoryPage(WebDriver driver) { + this.driver = driver; + } + + public String getHeader() { + Document doc = Jsoup.parse(driver.getPageSource()); + return doc.select("h1") + .stream() + .map(e -> e.text().trim()) + .filter(t -> !t.isEmpty()) + .findFirst() + .orElseThrow(() -> new RuntimeException("Category header not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/pages/CourseItem.java b/src/main/java/ru/kovbasa/pages/CourseItem.java new file mode 100644 index 0000000..79d0570 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CourseItem.java @@ -0,0 +1,6 @@ +package ru.kovbasa.pages; + +import java.time.LocalDate; + +public record CourseItem(String title, LocalDate startDate) { +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/pages/CourseLinkItem.java b/src/main/java/ru/kovbasa/pages/CourseLinkItem.java new file mode 100644 index 0000000..6ff0fa7 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CourseLinkItem.java @@ -0,0 +1,4 @@ +package ru.kovbasa.pages; + +public record CourseLinkItem(String title, String url) { +} diff --git a/src/main/java/ru/kovbasa/pages/CoursePage.java b/src/main/java/ru/kovbasa/pages/CoursePage.java new file mode 100644 index 0000000..d59a193 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CoursePage.java @@ -0,0 +1,139 @@ +package ru.kovbasa.pages; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.WebDriverWait; +import ru.kovbasa.elements.Button; +import ru.kovbasa.utils.PageUtils; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CoursePage { + private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s\\u00A0]*)\\s*₽"); + + + private final WebDriver driver; + + private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']"); + private final By fullPriceTab = By.xpath("//div[normalize-space()='Полная']/parent::div"); + private final By fullDiscountLabel = By.xpath("//p[contains(normalize-space(),'Полная стоимость со скидкой')]"); + private final By anyPriceText = By.xpath("//*[contains(normalize-space(),'₽')]"); + + public CoursePage(WebDriver driver) { + this.driver = driver; + } + + public String getCourseTitle() { + return getFirstH1(); + } + + public String getFirstH1() { + Document doc = Jsoup.parse(driver.getPageSource()); + return doc.select("h1") + .stream() + .map(e -> e.text().trim()) + .filter(t -> !t.isEmpty()) + .findFirst() + .orElseThrow(() -> new RuntimeException("Course title not found")); + } + + public LocalDate getCourseStartDate(LocalDate catalogDate) { + Document doc = Jsoup.parse(driver.getPageSource()); + + Element dateElement = doc.selectFirst("p.sc-3cb1l3-0"); + if (dateElement == null) { + throw new RuntimeException("Start date

not found on course page"); + } + + String text = dateElement.text().trim(); + String fullDate = text + ", " + catalogDate.getYear(); + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("d MMMM, yyyy", Locale.forLanguageTag("ru")); + + return LocalDate.parse(fullDate, formatter); + } + + public Button getEnrollButton() { + return new Button(driver.findElement(enrollButton)); + } + + public void clickEnroll() { + getEnrollButton().click(); + } + + public int getDiscountedFullPrice() { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10)); + final WebElement tab = wait.until(drv -> drv.findElements(fullPriceTab).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView({block:'center'});", tab); + try { + tab.click(); + } catch (RuntimeException ignored) { + new Actions(driver).moveToElement(tab).click().perform(); + } + + final WebElement label = wait.until(drv -> drv.findElements(fullDiscountLabel).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + final WebElement priceElement = findPriceElementNearLabel(label); + final String rawPrice = priceElement.getText(); + return parsePrice(rawPrice); + } + + public int getPriceForComparison() { + if (hasDisplayed(fullPriceTab)) { + try { + return getDiscountedFullPrice(); + } catch (RuntimeException ignored) { } + } + + final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10)); + final WebElement priceElement = wait.until(drv -> drv.findElements(anyPriceText).stream() + .filter(WebElement::isDisplayed) + .filter(el -> PRICE_PATTERN.matcher(el.getText()).find()) + .findFirst() + .orElse(null)); + + return parsePrice(priceElement.getText()); + } + + private WebElement findPriceElementNearLabel(WebElement label) { + try { + return label.findElement(By.xpath("following-sibling::div[1]")); + } catch (NoSuchElementException ignored) { + return label.findElement(By.xpath("following::div[contains(normalize-space(),'₽')][1]")); + } + } + + private int parsePrice(String rawPrice) { + final Matcher matcher = PRICE_PATTERN.matcher(rawPrice); + if (!matcher.find()) { + throw new IllegalArgumentException("Не удалось распарсить цену из строки: " + rawPrice); + } + final String normalized = matcher.group(1).replace('\u00A0', ' ').replace(" ", ""); + return Integer.parseInt(normalized); + } + + private boolean hasDisplayed(By by) { + return driver.findElements(by).stream().anyMatch(WebElement::isDisplayed); + } +} diff --git a/src/main/java/ru/kovbasa/pages/MainPage.java b/src/main/java/ru/kovbasa/pages/MainPage.java new file mode 100644 index 0000000..05198a6 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/MainPage.java @@ -0,0 +1,70 @@ +package ru.kovbasa.pages; + +import com.google.inject.Inject; +import org.openqa.selenium.By; +import org.openqa.selenium.ElementClickInterceptedException; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.WebDriverWait; +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.driver.WebDriverProvider; +import ru.kovbasa.utils.PageUtils; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class MainPage { + + private final WebDriver driver; + + private final By menuLearning = By.cssSelector("span[title='Обучение']"); + private final By categories = By.cssSelector("a[href*='/categories/']"); + + @Inject + public MainPage(WebDriverProvider provider) { + this.driver = provider.getDriver(); + } + + public MainPage open() { + driver.get(TestConfig.getBaseUrl()); + return this; + } + + public String clickRandomCategory() { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + + final WebElement menu = wait.until(drv -> drv.findElement(menuLearning)); + menu.click(); + + final List els = wait.until(drv -> { + final List found = drv.findElements(categories).stream() + .filter(WebElement::isDisplayed) + .toList(); + return found.isEmpty() ? null : found; + }); + + final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size())); + final String href = chosen.getAttribute("href"); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", chosen); + + try { + chosen.click(); + } catch (ElementClickInterceptedException ignored) { + new Actions(driver).moveToElement(chosen).click().perform(); + } + + final String categorySlug = href.substring(href.lastIndexOf('/') + 1); + if (!driver.getCurrentUrl().contains(categorySlug)) { + driver.get(TestConfig.getBaseUrl() + "/catalog/courses?categories=" + categorySlug); + } + + return href; + } +} diff --git a/src/main/java/ru/kovbasa/pages/PricedCourseItem.java b/src/main/java/ru/kovbasa/pages/PricedCourseItem.java new file mode 100644 index 0000000..edc0025 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/PricedCourseItem.java @@ -0,0 +1,4 @@ +package ru.kovbasa.pages; + +public record PricedCourseItem(String title, int price) { +} diff --git a/src/main/java/ru/kovbasa/utils/PageUtils.java b/src/main/java/ru/kovbasa/utils/PageUtils.java new file mode 100644 index 0000000..0c03c86 --- /dev/null +++ b/src/main/java/ru/kovbasa/utils/PageUtils.java @@ -0,0 +1,22 @@ +package ru.kovbasa.utils; + +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; + +public final class PageUtils { + + private PageUtils() { + } + + public static void removeBottomBanner(WebDriver driver) { + try { + ((JavascriptExecutor) driver) + .executeScript( + "document.querySelectorAll('.sc-11pdrud-1.cmIXWc')" + + ".forEach(e => e.remove());" + ); + } catch (Exception ignored) { + } + } + +} diff --git a/src/main/java/ru/otus/stub/helper/HttpHelper.java b/src/main/java/ru/otus/stub/helper/HttpHelper.java new file mode 100644 index 0000000..71a93a0 --- /dev/null +++ b/src/main/java/ru/otus/stub/helper/HttpHelper.java @@ -0,0 +1,56 @@ +package ru.otus.stub.helper; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import ru.otus.stub.model.Course; +import ru.otus.stub.model.User; +import ru.otus.stub.model.UserScore; + +import java.util.List; + +public class HttpHelper { + + private final String baseUri; + private final String basePath; + + public HttpHelper(String baseUri, String basePath) { + this.baseUri = baseUri; + this.basePath = basePath; + } + + public List getAllUsers() { + return RestAssured.given() + .baseUri(baseUri) + .basePath(basePath) + .when() + .get("/user/get/all") + .then() + .statusCode(200) + .extract() + .as(new TypeRef<>() { }); + } + + public List getAllCourses() { + return RestAssured.given() + .baseUri(baseUri) + .basePath(basePath) + .when() + .get("/cource/get/all") + .then() + .statusCode(200) + .extract() + .as(new TypeRef<>() { }); + } + + public UserScore getUserScore(long id) { + return RestAssured.given() + .baseUri(baseUri) + .basePath(basePath) + .when() + .get("/user/get/{id}", id) + .then() + .statusCode(200) + .extract() + .as(UserScore.class); + } +} diff --git a/src/main/java/ru/otus/stub/helper/MqHelper.java b/src/main/java/ru/otus/stub/helper/MqHelper.java new file mode 100644 index 0000000..da70b24 --- /dev/null +++ b/src/main/java/ru/otus/stub/helper/MqHelper.java @@ -0,0 +1,38 @@ +package ru.otus.stub.helper; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class MqHelper { + + private final Map> queues = new ConcurrentHashMap<>(); + + public void send(String queueName, String payload) { + queue(queueName).add(payload); + } + + public String receive(String queueName, Duration timeout) { + try { + return queue(queueName).poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while receiving from queue: " + queueName, exception); + } + } + + public int size(String queueName) { + return queue(queueName).size(); + } + + public void clear(String queueName) { + queue(queueName).clear(); + } + + private BlockingQueue queue(String queueName) { + return queues.computeIfAbsent(queueName, key -> new LinkedBlockingQueue<>()); + } +} diff --git a/src/main/java/ru/otus/stub/helper/SoapHelper.java b/src/main/java/ru/otus/stub/helper/SoapHelper.java new file mode 100644 index 0000000..effa1f5 --- /dev/null +++ b/src/main/java/ru/otus/stub/helper/SoapHelper.java @@ -0,0 +1,48 @@ +package ru.otus.stub.helper; + +import io.restassured.RestAssured; +import io.restassured.path.xml.XmlPath; +import ru.otus.stub.model.UserScore; + +public class SoapHelper { + + private final String baseUri; + private final String basePath; + + public SoapHelper(String baseUri, String basePath) { + this.baseUri = baseUri; + this.basePath = basePath; + } + + public UserScore getUserScore(long id) { + final String request = """ + + + + + ${id} + + + + """.replace("${id}", String.valueOf(id)); + + final String response = RestAssured.given() + .baseUri(baseUri) + .basePath(basePath) + .contentType("text/xml; charset=UTF-8") + .body(request) + .when() + .post("/soap/user/score") + .then() + .statusCode(200) + .extract() + .asString(); + + final XmlPath xml = new XmlPath(response); + final String name = xml.getString("Envelope.Body.GetUserScoreResponse.name"); + final int score = xml.getInt("Envelope.Body.GetUserScoreResponse.score"); + return new UserScore(name, score); + } +} diff --git a/src/main/java/ru/otus/stub/helper/SqlHelper.java b/src/main/java/ru/otus/stub/helper/SqlHelper.java new file mode 100644 index 0000000..3e64e81 --- /dev/null +++ b/src/main/java/ru/otus/stub/helper/SqlHelper.java @@ -0,0 +1,63 @@ +package ru.otus.stub.helper; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class SqlHelper { + + private final String jdbcUrl; + private final String username; + private final String password; + + public SqlHelper(String jdbcUrl, String username, String password) { + this.jdbcUrl = jdbcUrl; + this.username = username; + this.password = password; + } + + public int executeUpdate(String sql) { + try (Connection connection = openConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + return statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("SQL update failed: " + sql, exception); + } + } + + public List> selectRows(String sql) { + try (Connection connection = openConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + return mapRows(resultSet); + } catch (SQLException exception) { + throw new IllegalStateException("SQL query failed: " + sql, exception); + } + } + + private Connection openConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl, username, password); + } + + private List> mapRows(ResultSet resultSet) throws SQLException { + final List> rows = new ArrayList<>(); + final ResultSetMetaData metaData = resultSet.getMetaData(); + final int columnsCount = metaData.getColumnCount(); + + while (resultSet.next()) { + final Map row = new LinkedHashMap<>(); + for (int column = 1; column <= columnsCount; column++) { + row.put(metaData.getColumnLabel(column), resultSet.getObject(column)); + } + rows.add(row); + } + return rows; + } +} diff --git a/src/main/java/ru/otus/stub/model/Course.java b/src/main/java/ru/otus/stub/model/Course.java new file mode 100644 index 0000000..e5b4a45 --- /dev/null +++ b/src/main/java/ru/otus/stub/model/Course.java @@ -0,0 +1,4 @@ +package ru.otus.stub.model; + +public record Course(String name, int price) { +} diff --git a/src/main/java/ru/otus/stub/model/User.java b/src/main/java/ru/otus/stub/model/User.java new file mode 100644 index 0000000..89ac305 --- /dev/null +++ b/src/main/java/ru/otus/stub/model/User.java @@ -0,0 +1,4 @@ +package ru.otus.stub.model; + +public record User(String name, String cource, String email, int age) { +} diff --git a/src/main/java/ru/otus/stub/model/UserScore.java b/src/main/java/ru/otus/stub/model/UserScore.java new file mode 100644 index 0000000..30b1c65 --- /dev/null +++ b/src/main/java/ru/otus/stub/model/UserScore.java @@ -0,0 +1,4 @@ +package ru.otus.stub.model; + +public record UserScore(String name, int score) { +} diff --git a/src/test/java/ru/kovbasa/bdd/CucumberTest.java b/src/test/java/ru/kovbasa/bdd/CucumberTest.java new file mode 100644 index 0000000..d367935 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/CucumberTest.java @@ -0,0 +1,17 @@ +package ru.kovbasa.bdd; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features/stub_frontend.feature") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "ru.kovbasa.bdd") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") +public class CucumberTest { +} diff --git a/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java b/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java new file mode 100644 index 0000000..12c1ed6 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java @@ -0,0 +1,60 @@ +package ru.kovbasa.bdd.hooks; + +import com.google.inject.Injector; +import io.cucumber.java.After; +import io.cucumber.java.AfterAll; +import io.cucumber.java.BeforeAll; +import com.github.tomakehurst.wiremock.WireMockServer; +import ru.kovbasa.config.InjectorProvider; +import ru.kovbasa.driver.WebDriverProvider; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +public class Hooks { + + private static WireMockServer frontendStubServer; + + @BeforeAll + public static void beforeAll() { + if (frontendStubServer == null) { + frontendStubServer = new WireMockServer(wireMockConfig().port(8089)); + frontendStubServer.start(); + frontendStubServer.stubFor(get(urlPathEqualTo("/frontend")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/html; charset=UTF-8") + .withBody(""" + + + + + Stub Frontend + + +

Stub Frontend

+

UI connected to WireMock stub backend

+ + + """))); + frontendStubServer.stubFor(get(urlPathEqualTo("/favicon.ico")) + .willReturn(aResponse().withStatus(204))); + } + } + + @After + public void afterScenario() { + final Injector injector = InjectorProvider.getInjector(); + injector.getInstance(WebDriverProvider.class).quit(); + } + + @AfterAll + public static void afterAll() { + if (frontendStubServer != null) { + frontendStubServer.stop(); + frontendStubServer = null; + } + } +} diff --git a/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java b/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java new file mode 100644 index 0000000..03e9598 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java @@ -0,0 +1,159 @@ +package ru.kovbasa.bdd.steps; + +import com.google.inject.Injector; +import io.cucumber.java.ru.Дано; +import io.cucumber.java.ru.И; +import io.cucumber.java.ru.Когда; +import io.cucumber.java.ru.Тогда; +import org.junit.jupiter.api.Assertions; +import ru.kovbasa.config.InjectorProvider; +import ru.kovbasa.pages.CatalogPage; +import ru.kovbasa.pages.CourseItem; +import ru.kovbasa.pages.CoursePage; +import ru.kovbasa.pages.PricedCourseItem; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +public class CatalogSteps { + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru")); + + private CoursePage openedCoursePage; + private List coursesStartingFromDate; + private LocalDate searchDate; + private PricedCourseItem mostExpensiveCourse; + private PricedCourseItem cheapestCourse; + private PricedCourseItem mostExpensiveCatalogCourse; + private PricedCourseItem cheapestCatalogCourse; + + @Дано("Открыт браузер {word}") + public void openBrowser(String browser) { + System.setProperty("browser", browser.toLowerCase(Locale.ROOT)); + } + + @Когда("Открыт каталог курсов") + public void openCatalog() { + getCatalogPage().open(); + } + + @Когда("Найден курс {string} и выбран случайный из найденных") + public void findCourseAndOpenRandom(String courseName) { + openedCoursePage = getCatalogPage().clickRandomCourseByName(courseName); + } + + @Тогда("Заголовок страницы курса содержит {string}") + public void coursePageTitleContains(String expectedCourseName) { + final String title = openedCoursePage.getCourseTitle(); + Assertions.assertTrue( + title.toLowerCase(Locale.ROOT).contains(expectedCourseName.toLowerCase(Locale.ROOT)), + "Заголовок страницы курса должен содержать: " + expectedCourseName + ", фактически: " + title + ); + } + + @Когда("Найдены курсы со стартом {string} или позже") + public void findCoursesFromDate(String date) { + searchDate = LocalDate.parse(date, DATE_FORMATTER); + coursesStartingFromDate = getCatalogPage().findCoursesStartingFrom(searchDate); + } + + @Тогда("В консоль выведены найденные курсы и даты старта") + public void printCoursesAndStartDates() { + Assertions.assertFalse(coursesStartingFromDate.isEmpty(), "Не найдено курсов по заданной дате"); + + coursesStartingFromDate.forEach(course -> System.out.println( + "Курс: " + course.title() + ", дата старта: " + course.startDate().format(DATE_FORMATTER) + )); + + final boolean allAfterOrEqual = coursesStartingFromDate.stream() + .allMatch(course -> !course.startDate().isBefore(searchDate)); + + Assertions.assertTrue( + allAfterOrEqual, + "Среди найденных есть курсы с датой старта раньше " + searchDate.format(DATE_FORMATTER) + ); + } + + @Когда("Открыт раздел Обучение и Подготовительные курсы") + public void openPreparatoryCourses() { + getCatalogPage().openPreparatoryCourses(); + } + + @И("Выбраны самый дорогой и самый дешевый курсы с помощью filter") + public void findMostExpensiveAndCheapestByFilter() { + final List pricedCourses = getCatalogPage().getPreparatoryCoursesWithDiscountedFullPrice(); + Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой"); + + final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow(); + final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow(); + + mostExpensiveCourse = pricedCourses.stream() + .filter(course -> course.price() == maxPrice) + .findFirst() + .orElseThrow(); + + cheapestCourse = pricedCourses.stream() + .filter(course -> course.price() == minPrice) + .findFirst() + .orElseThrow(); + } + + @Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе") + public void printMostExpensiveAndCheapestCourse() { + System.out.println( + "Самый дорогой курс: " + mostExpensiveCourse.title() + ", цена: " + mostExpensiveCourse.price() + ); + System.out.println( + "Самый дешевый курс: " + cheapestCourse.title() + ", цена: " + cheapestCourse.price() + ); + + Assertions.assertTrue( + mostExpensiveCourse.price() >= cheapestCourse.price(), + "Цена самого дорогого курса должна быть не меньше цены самого дешевого" + ); + } + + @Когда("В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter") + public void findMostExpensiveAndCheapestInCatalogByFullDiscountedPrice() { + final List pricedCourses = getCatalogPage().getCatalogCoursesWithDiscountedFullPrice(); + Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой в общем каталоге"); + + final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow(); + final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow(); + + mostExpensiveCatalogCourse = pricedCourses.stream() + .filter(course -> course.price() == maxPrice) + .findFirst() + .orElseThrow(); + + cheapestCatalogCourse = pricedCourses.stream() + .filter(course -> course.price() == minPrice) + .findFirst() + .orElseThrow(); + } + + @Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге") + public void printMostExpensiveAndCheapestCourseInCatalog() { + System.out.println( + "Самый дорогой курс в каталоге: " + + mostExpensiveCatalogCourse.title() + ", цена: " + mostExpensiveCatalogCourse.price() + ); + System.out.println( + "Самый дешевый курс в каталоге: " + + cheapestCatalogCourse.title() + ", цена: " + cheapestCatalogCourse.price() + ); + + Assertions.assertTrue( + mostExpensiveCatalogCourse.price() >= cheapestCatalogCourse.price(), + "Цена самого дорогого курса в каталоге должна быть не меньше цены самого дешевого" + ); + } + + private CatalogPage getCatalogPage() { + final Injector injector = InjectorProvider.getInjector(); + return injector.getInstance(CatalogPage.class); + } +} diff --git a/src/test/java/ru/kovbasa/bdd/steps/StubFrontendSteps.java b/src/test/java/ru/kovbasa/bdd/steps/StubFrontendSteps.java new file mode 100644 index 0000000..cefbf67 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/steps/StubFrontendSteps.java @@ -0,0 +1,53 @@ +package ru.kovbasa.bdd.steps; + +import com.google.inject.Injector; +import io.cucumber.java.ru.Дано; +import io.cucumber.java.ru.Когда; +import io.cucumber.java.ru.Тогда; +import org.junit.jupiter.api.Assertions; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import ru.kovbasa.config.InjectorProvider; +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.driver.WebDriverProvider; + +import java.util.Locale; + +public class StubFrontendSteps { + + @Дано("Открыт браузер для frontend {word}") + public void openBrowser(String browser) { + final String existingBrowser = System.getProperty("browser"); + if (existingBrowser == null || existingBrowser.isBlank()) { + System.setProperty("browser", browser.toLowerCase(Locale.ROOT)); + } + } + + @Когда("Открыта страница stub frontend") + public void openStubFrontend() { + getDriver().get(TestConfig.getBaseUrl() + "/frontend"); + } + + @Тогда("Заголовок страницы равен {string}") + public void titleShouldBe(String expectedTitle) { + final String actualTitle = getDriver().getTitle(); + Assertions.assertEquals(expectedTitle, actualTitle); + } + + @Тогда("На странице есть заголовок {string}") + public void pageHasHeading(String expectedHeading) { + final String actualHeading = getDriver().findElement(By.id("title")).getText(); + Assertions.assertEquals(expectedHeading, actualHeading); + } + + @Тогда("На странице есть описание {string}") + public void pageHasDescription(String expectedDescription) { + final String actualDescription = getDriver().findElement(By.id("description")).getText(); + Assertions.assertEquals(expectedDescription, actualDescription); + } + + private WebDriver getDriver() { + final Injector injector = InjectorProvider.getInjector(); + return injector.getInstance(WebDriverProvider.class).getDriver(); + } +} diff --git a/src/test/java/ru/otus/stub/tests/BaseWireMockStubTest.java b/src/test/java/ru/otus/stub/tests/BaseWireMockStubTest.java new file mode 100644 index 0000000..5770794 --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/BaseWireMockStubTest.java @@ -0,0 +1,92 @@ +package ru.otus.stub.tests; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +abstract class BaseWireMockStubTest { + + @RegisterExtension + protected static final WireMockExtension WIREMOCK = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + @BeforeEach + void registerContracts() { + WIREMOCK.resetAll(); + + WIREMOCK.stubFor(get(urlPathEqualTo("/user/get/all")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + [ + { "name": "Test user", "cource": "QA", "email": "test@test.test", "age": 23 }, + { "name": "Test user 2", "cource": "QA java", "email": "test2@test.test", "age": 27 } + ] + """))); + + WIREMOCK.stubFor(get(urlPathEqualTo("/cource/get/all")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + [ + { "name": "QA java", "price": 15000 }, + { "name": "Java", "price": 12000 } + ] + """))); + + WIREMOCK.stubFor(get(urlPathMatching("/user/get/\\d+")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { "name": "Test user", "score": 78 } + """))); + + WIREMOCK.stubFor(post(urlPathEqualTo("/soap/user/score")) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.containing("GetUserScoreRequest")) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.containing("")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/xml; charset=UTF-8") + .withBody(""" + + + + Test user + 78 + + + + """))); + + WIREMOCK.stubFor(get(urlPathEqualTo("/frontend")) + .withQueryParam("active", equalTo("true")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/html; charset=UTF-8") + .withBody(""" + + + + + Stub Frontend + + +

Stub Frontend

+

UI connected to WireMock stub backend

+ + + """))); + } +} diff --git a/src/test/java/ru/otus/stub/tests/HttpHelperTest.java b/src/test/java/ru/otus/stub/tests/HttpHelperTest.java new file mode 100644 index 0000000..41dbb1c --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/HttpHelperTest.java @@ -0,0 +1,45 @@ +package ru.otus.stub.tests; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.otus.stub.helper.HttpHelper; +import ru.otus.stub.model.Course; +import ru.otus.stub.model.User; +import ru.otus.stub.model.UserScore; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class HttpHelperTest extends BaseWireMockStubTest { + + private HttpHelper httpHelper; + + @BeforeEach + void setUpHelper() { + httpHelper = new HttpHelper(WIREMOCK.baseUrl(), ""); + } + + @Test + void shouldGetAllUsers() { + final List users = httpHelper.getAllUsers(); + assertFalse(users.isEmpty()); + assertEquals("Test user", users.getFirst().name()); + } + + @Test + void shouldGetAllCourses() { + final List courses = httpHelper.getAllCourses(); + assertFalse(courses.isEmpty()); + assertEquals("QA java", courses.getFirst().name()); + } + + @Test + void shouldGetUserScore() { + final UserScore userScore = httpHelper.getUserScore(1); + assertEquals("Test user", userScore.name()); + assertEquals(78, userScore.score()); + } +} diff --git a/src/test/java/ru/otus/stub/tests/MqHelperTest.java b/src/test/java/ru/otus/stub/tests/MqHelperTest.java new file mode 100644 index 0000000..3a3f01a --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/MqHelperTest.java @@ -0,0 +1,23 @@ +package ru.otus.stub.tests; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import ru.otus.stub.helper.MqHelper; + +import java.time.Duration; + +class MqHelperTest { + + @Test + void shouldSendAndReceiveMessage() { + final MqHelper mqHelper = new MqHelper(); + final String queue = "scores"; + final String payload = "{\"name\":\"Test user\",\"score\":78}"; + + mqHelper.send(queue, payload); + final String received = mqHelper.receive(queue, Duration.ofSeconds(1)); + + Assertions.assertEquals(payload, received); + Assertions.assertEquals(0, mqHelper.size(queue)); + } +} diff --git a/src/test/java/ru/otus/stub/tests/SoapHelperTest.java b/src/test/java/ru/otus/stub/tests/SoapHelperTest.java new file mode 100644 index 0000000..4bceb26 --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/SoapHelperTest.java @@ -0,0 +1,24 @@ +package ru.otus.stub.tests; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.otus.stub.helper.SoapHelper; +import ru.otus.stub.model.UserScore; + +class SoapHelperTest extends BaseWireMockStubTest { + + private SoapHelper soapHelper; + + @BeforeEach + void setUpHelper() { + soapHelper = new SoapHelper(WIREMOCK.baseUrl(), ""); + } + + @Test + void shouldGetUserScoreBySoap() { + final UserScore userScore = soapHelper.getUserScore(1); + Assertions.assertEquals("Test user", userScore.name()); + Assertions.assertEquals(78, userScore.score()); + } +} diff --git a/src/test/java/ru/otus/stub/tests/SqlHelperTest.java b/src/test/java/ru/otus/stub/tests/SqlHelperTest.java new file mode 100644 index 0000000..95aa7f0 --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/SqlHelperTest.java @@ -0,0 +1,31 @@ +package ru.otus.stub.tests; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import ru.otus.stub.helper.SqlHelper; + +import java.util.List; +import java.util.Map; + +class SqlHelperTest { + + @Test + void shouldReadDataFromDatabase() { + final String jdbcUrl = "jdbc:h2:mem:stubdb;DB_CLOSE_DELAY=-1"; + final SqlHelper sqlHelper = new SqlHelper(jdbcUrl, "sa", ""); + + sqlHelper.executeUpdate(""" + CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY, + name VARCHAR(100), + score INT + ) + """); + sqlHelper.executeUpdate("INSERT INTO users(id, name, score) VALUES (1, 'Test user', 78)"); + + final List> rows = sqlHelper.selectRows("SELECT name, score FROM users WHERE id = 1"); + Assertions.assertEquals(1, rows.size()); + Assertions.assertEquals("Test user", rows.getFirst().get("NAME")); + Assertions.assertEquals(78, ((Number) rows.getFirst().get("SCORE")).intValue()); + } +} diff --git a/src/test/java/ru/otus/stub/tests/StubContractTest.java b/src/test/java/ru/otus/stub/tests/StubContractTest.java new file mode 100644 index 0000000..e0247ed --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/StubContractTest.java @@ -0,0 +1,50 @@ +package ru.otus.stub.tests; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.otus.stub.helper.HttpHelper; +import ru.otus.stub.model.Course; +import ru.otus.stub.model.User; +import ru.otus.stub.model.UserScore; + +import java.util.List; + +class StubContractTest extends BaseWireMockStubTest { + + private HttpHelper httpHelper; + + @BeforeEach + void setUpHelper() { + httpHelper = new HttpHelper(WIREMOCK.baseUrl(), ""); + } + + @Test + void usersContractShouldContainRequiredFieldsAndExpectedValues() { + final List users = httpHelper.getAllUsers(); + Assertions.assertFalse(users.isEmpty(), "Users list must not be empty"); + + final User first = users.getFirst(); + Assertions.assertEquals("Test user", first.name()); + Assertions.assertEquals("QA", first.cource()); + Assertions.assertEquals("test@test.test", first.email()); + Assertions.assertEquals(23, first.age()); + } + + @Test + void coursesContractShouldContainExpectedItems() { + final List courses = httpHelper.getAllCourses(); + Assertions.assertTrue(courses.size() >= 2, "Courses list must contain at least 2 items"); + + final Course first = courses.getFirst(); + Assertions.assertEquals("QA java", first.name()); + Assertions.assertEquals(15000, first.price()); + } + + @Test + void userScoreContractShouldContainExpectedValues() { + final UserScore score = httpHelper.getUserScore(1); + Assertions.assertEquals("Test user", score.name()); + Assertions.assertEquals(78, score.score()); + } +} diff --git a/src/test/java/ru/otus/stub/tests/StubSchemaTest.java b/src/test/java/ru/otus/stub/tests/StubSchemaTest.java new file mode 100644 index 0000000..1369159 --- /dev/null +++ b/src/test/java/ru/otus/stub/tests/StubSchemaTest.java @@ -0,0 +1,42 @@ +package ru.otus.stub.tests; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; + +import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; + +class StubSchemaTest extends BaseWireMockStubTest { + + @Test + void usersResponseShouldMatchSchema() { + RestAssured.given() + .baseUri(WIREMOCK.baseUrl()) + .when() + .get("/user/get/all") + .then() + .statusCode(200) + .body(matchesJsonSchemaInClasspath("schemas/users-schema.json")); + } + + @Test + void coursesResponseShouldMatchSchema() { + RestAssured.given() + .baseUri(WIREMOCK.baseUrl()) + .when() + .get("/cource/get/all") + .then() + .statusCode(200) + .body(matchesJsonSchemaInClasspath("schemas/courses-schema.json")); + } + + @Test + void scoreResponseShouldMatchSchema() { + RestAssured.given() + .baseUri(WIREMOCK.baseUrl()) + .when() + .get("/user/get/{id}", 1) + .then() + .statusCode(200) + .body(matchesJsonSchemaInClasspath("schemas/score-schema.json")); + } +} diff --git a/src/test/resources/features/catalog.feature b/src/test/resources/features/catalog.feature new file mode 100644 index 0000000..cd23d5a --- /dev/null +++ b/src/test/resources/features/catalog.feature @@ -0,0 +1,29 @@ +# language: ru + +Функция: Каталог курсов OTUS + Как пользователь + Я хочу работать с каталогом курсов через BDD + Чтобы проверять поиск, фильтрацию по датам и выбор по цене + + Сценарий: Выбор браузера и поиск курса по названию со случайным выбором + Дано Открыт браузер Chrome + Когда Открыт каталог курсов + И Найден курс "Python" и выбран случайный из найденных + Тогда Заголовок страницы курса содержит "Python" + + Сценарий: Поиск курсов, стартующих в указанную дату или позже + Дано Открыт браузер Chrome + Когда Открыт каталог курсов + И Найдены курсы со стартом "01.01.2025" или позже + Тогда В консоль выведены найденные курсы и даты старта + + Сценарий: Поиск самого дорогого и самого дешевого подготовительного курса + Дано Открыт браузер Chrome + Когда Открыт раздел Обучение и Подготовительные курсы + И Выбраны самый дорогой и самый дешевый курсы с помощью filter + Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе + + Сценарий: Поиск самого дорогого и самого дешевого курса в каталоге по полной стоимости со скидкой + Дано Открыт браузер Chrome + Когда В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter + Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге diff --git a/src/test/resources/features/stub_frontend.feature b/src/test/resources/features/stub_frontend.feature new file mode 100644 index 0000000..1cf6ed3 --- /dev/null +++ b/src/test/resources/features/stub_frontend.feature @@ -0,0 +1,10 @@ +# language: ru + +Функция: Stub Frontend + + Сценарий: Проверка подключения frontend к stub-серверу + Дано Открыт браузер для frontend chrome + Когда Открыта страница stub frontend + Тогда Заголовок страницы равен "Stub Frontend" + И На странице есть заголовок "Stub Frontend" + И На странице есть описание "UI connected to WireMock stub backend" diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..ec7d72c --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + + %d{HH:mm:ss} %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties new file mode 100644 index 0000000..3da1c9c --- /dev/null +++ b/src/test/resources/logging.properties @@ -0,0 +1,8 @@ +handlers= java.util.logging.ConsoleHandler +.level= WARNING + +java.util.logging.ConsoleHandler.level = SEVERE +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +org.openqa.selenium.devtools.level = SEVERE +org.openqa.selenium.devtools.CdpVersionFinder.level = SEVERE diff --git a/src/test/resources/schemas/courses-schema.json b/src/test/resources/schemas/courses-schema.json new file mode 100644 index 0000000..28f6e31 --- /dev/null +++ b/src/test/resources/schemas/courses-schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "price"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "price": { "type": "integer", "minimum": 0 } + } + } +} diff --git a/src/test/resources/schemas/score-schema.json b/src/test/resources/schemas/score-schema.json new file mode 100644 index 0000000..4a00851 --- /dev/null +++ b/src/test/resources/schemas/score-schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["name", "score"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "score": { "type": "integer", "minimum": 0, "maximum": 100 } + } +} diff --git a/src/test/resources/schemas/users-schema.json b/src/test/resources/schemas/users-schema.json new file mode 100644 index 0000000..5e17813 --- /dev/null +++ b/src/test/resources/schemas/users-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "cource", "email", "age"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "cource": { "type": "string" }, + "email": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + } + } +}