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