tagsList = link.tags();
+ boolean isTagRemoved = tagsList.removeIf(tag -> tag.tag().equals(tagRemoveRequest.tag()));
+
+ if (!isTagRemoved) {
+ log.error("Тег {} не найден у ссылки в чате с ID {}", tagRemoveRequest.tag(), tgChatId);
+ throw new TagNotExistException(
+ "Тег " + tagRemoveRequest.tag() + " не найден у ссылки в чате с ID " + tgChatId);
+ }
+
+ link.tags(tagsList);
+
+ return linkMapper.linkToLinkResponse(link);
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/client/BaseWebClient.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/BaseWebClient.java
new file mode 100644
index 0000000..81c745a
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/BaseWebClient.java
@@ -0,0 +1,27 @@
+package backend.academy.scrapper.tracker.client;
+
+import backend.academy.scrapper.configuration.api.WebClientProperties;
+import io.netty.channel.ChannelOption;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+public abstract class BaseWebClient {
+ protected final WebClient webClient;
+ protected final WebClientProperties webClientProperties;
+
+ protected BaseWebClient(String baseUrl, WebClientProperties webClientProperties) {
+ this.webClientProperties = webClientProperties;
+
+ // Настраиваем таймауты через HttpClient
+ HttpClient httpClient = HttpClient.create()
+ .responseTimeout(webClientProperties.responseTimeout()) // Таймаут на ответ
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int)
+ webClientProperties.connectTimeout().toMillis());
+
+ this.webClient = WebClient.builder()
+ .baseUrl(baseUrl)
+ .clientConnector(new ReactorClientHttpConnector(httpClient))
+ .build();
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/client/GitHubClient.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/GitHubClient.java
new file mode 100644
index 0000000..9ebb47a
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/GitHubClient.java
@@ -0,0 +1,128 @@
+package backend.academy.scrapper.tracker.client;
+
+import backend.academy.scrapper.configuration.ScrapperConfig;
+import backend.academy.scrapper.configuration.api.WebClientProperties;
+import backend.academy.scrapper.tracker.request.GitHubRequest;
+import backend.academy.scrapper.tracker.response.github.GitHubResponse;
+import backend.academy.scrapper.tracker.response.github.IssueResponse;
+import backend.academy.scrapper.tracker.response.github.PullRequestResponse;
+import io.github.resilience4j.retry.annotation.Retry;
+import java.time.OffsetDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+
+/**
+ * было https://github.com/Delphington/TestApiGitHubs/pull/1 стало
+ * https://api.github.com/repos/Delphington/TestApiGitHubs/pulls/1
+ *
+ * было https://github.com/Delphington/TestApiGitHubs стало https://api.github.com/repos/Delphington/TestApiGitHubs
+ *
+ *
было https://github.com/Delphington/TestApiGitHubs/issues/2 стало
+ * https://api.github.com/repos/Delphington/TestApiGitHubs/issues/2 https://api.github.com/repos/Delphington/Delphington
+ */
+
+/// **
+
+@Slf4j
+public class GitHubClient extends BaseWebClient {
+
+ public GitHubClient(ScrapperConfig.GithubCredentials githubCredentials, WebClientProperties webClientProperties) {
+ super(githubCredentials.githubUrl(), webClientProperties);
+ if (githubCredentials.githubToken() != null
+ && !githubCredentials.githubToken().trim().isEmpty()) {
+ webClient.mutate().defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + githubCredentials.githubToken());
+ }
+ }
+
+ @Retry(name = "getFetchDateGitHub", fallbackMethod = "getFetchDateFallback")
+ public Optional getFetchDate(GitHubRequest gitHubRequest) {
+ log.info("GitHubClient getFetchDate {}", gitHubRequest);
+ return Optional.ofNullable(webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/{userName}/{repositoryName}")
+ .build(gitHubRequest.userName(), gitHubRequest.repositoryName()))
+ .retrieve()
+ .bodyToMono(GitHubResponse.class)
+ .timeout(webClientProperties.globalTimeout())
+ .block());
+ }
+
+ @Retry(name = "fetchPullRequestGitHub", fallbackMethod = "fetchPullRequestFallback")
+ public Optional> fetchPullRequest(GitHubRequest gitHubRequest, OffsetDateTime since) {
+ if (since == null) {
+ return Optional.of(Collections.emptyList());
+ }
+
+ List list = webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/{userName}/{repositoryName}/pulls")
+ .build(gitHubRequest.userName(), gitHubRequest.repositoryName()))
+ .retrieve()
+ .bodyToFlux(PullRequestResponse.class)
+ .collectList()
+ .timeout(webClientProperties.globalTimeout())
+ .blockOptional()
+ .orElse(Collections.emptyList());
+
+ return Optional.of(
+ list.stream().filter(i -> i.updatedAt().isAfter(since)).collect(Collectors.toList()));
+ }
+
+ @Retry(name = "fetchIssueGitHub", fallbackMethod = "fetchIssueFallback")
+ public Optional> fetchIssue(GitHubRequest gitHubRequest, OffsetDateTime since) {
+ if (since == null) {
+ return Optional.of(Collections.emptyList());
+ }
+
+ List list = webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/{userName}/{repositoryName}/issues")
+ .build(gitHubRequest.userName(), gitHubRequest.repositoryName()))
+ .retrieve()
+ .bodyToFlux(IssueResponse.class)
+ .collectList()
+ .timeout(webClientProperties.globalTimeout())
+ .blockOptional()
+ .orElse(Collections.emptyList());
+
+ log.debug("GitHubClient Issue {}", gitHubRequest);
+
+ return Optional.of(
+ list.stream().filter(i -> i.updatedAt().isAfter(since)).collect(Collectors.toList()));
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional> fetchPullRequestFallback(
+ GitHubRequest request, OffsetDateTime since, Exception ex) {
+ log.error(
+ "Ошибка при получении PullRequest для репозитория {}, request = {}, since = {}",
+ ex.getMessage(),
+ request,
+ since);
+ return Optional.empty();
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional> fetchIssueFallback(
+ GitHubRequest request, OffsetDateTime since, Exception ex) {
+ log.error(
+ "Ошибка при получении Issues для репозитория {}, request = {}, since = {}",
+ ex.getMessage(),
+ request,
+ since);
+ return Optional.empty();
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional getFetchDateFallback(GitHubRequest request, Exception ex) {
+ log.error("Ошибка при получении даты для репозитория, request = {}, ex = {}", request, ex.getMessage());
+ return Optional.empty();
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/client/StackOverFlowClient.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/StackOverFlowClient.java
new file mode 100644
index 0000000..f303265
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/client/StackOverFlowClient.java
@@ -0,0 +1,92 @@
+package backend.academy.scrapper.tracker.client;
+
+import backend.academy.scrapper.configuration.ScrapperConfig;
+import backend.academy.scrapper.configuration.api.WebClientProperties;
+import backend.academy.scrapper.tracker.request.StackOverFlowRequest;
+import backend.academy.scrapper.tracker.response.stack.AnswersResponse;
+import backend.academy.scrapper.tracker.response.stack.CommentResponse;
+import backend.academy.scrapper.tracker.response.stack.QuestionResponse;
+import io.github.resilience4j.retry.annotation.Retry;
+import java.util.Optional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class StackOverFlowClient extends BaseWebClient {
+
+ public StackOverFlowClient(
+ ScrapperConfig.StackOverflowCredentials stackOverflowCredentials, WebClientProperties webClientProperties) {
+ super(stackOverflowCredentials.stackOverFlowUrl(), webClientProperties);
+ if (stackOverflowCredentials.key() != null
+ && !stackOverflowCredentials.key().isEmpty()) {
+ webClient.mutate().defaultHeader("key", stackOverflowCredentials.key());
+ }
+ if (stackOverflowCredentials.accessToken() != null
+ && !stackOverflowCredentials.accessToken().isEmpty()) {
+ webClient.mutate().defaultHeader("access_token", stackOverflowCredentials.accessToken());
+ }
+ }
+
+ @Retry(name = "fetchQuestionStackOverFlow", fallbackMethod = "fetchQuestionFallback")
+ public Optional fetchQuestion(StackOverFlowRequest stackOverFlowRequest) {
+ return Optional.ofNullable(webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/questions/{chatId}")
+ .queryParam("site", stackOverFlowRequest.site())
+ .queryParam("order", stackOverFlowRequest.order())
+ .queryParam("sort", stackOverFlowRequest.sort())
+ .build(stackOverFlowRequest.number()))
+ .retrieve()
+ .bodyToMono(QuestionResponse.class)
+ .timeout(webClientProperties.globalTimeout())
+ .block());
+ }
+
+ @Retry(name = "fetchAnswerStackOverFlow", fallbackMethod = "fetchAnswerFallback")
+ public Optional fetchAnswer(StackOverFlowRequest stackOverFlowRequest) {
+ return Optional.ofNullable(webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/questions/{chatId}/answers")
+ .queryParam("site", stackOverFlowRequest.site())
+ .queryParam("filter", stackOverFlowRequest.filter())
+ .build(stackOverFlowRequest.number()))
+ .retrieve()
+ .bodyToMono(AnswersResponse.class)
+ .timeout(webClientProperties.globalTimeout())
+ .block());
+ }
+
+ @Retry(name = "fetchCommentStackOverFlow", fallbackMethod = "fetchCommentFallback")
+ public Optional fetchComment(StackOverFlowRequest stackOverFlowRequest) {
+ return Optional.ofNullable(webClient
+ .get()
+ .uri(uriBuilder -> uriBuilder
+ .path("/questions/{chatId}/comments")
+ .queryParam("site", stackOverFlowRequest.site())
+ .queryParam("filter", stackOverFlowRequest.filter())
+ .build(stackOverFlowRequest.number()))
+ .retrieve()
+ .bodyToMono(CommentResponse.class)
+ .timeout(webClientProperties.globalTimeout())
+ .block());
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional fetchQuestionFallback(StackOverFlowRequest stackOverFlowRequest, Exception ex) {
+ log.error("Произошла ошибка stackOverFlowRequest = {}, ex = {}", stackOverFlowRequest, ex.getMessage());
+ return Optional.empty();
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional fetchAnswerFallback(StackOverFlowRequest stackOverFlowRequest, Exception ex) {
+ log.error("Произошла ошибка stackOverFlowRequest= {}, ex = {}", stackOverFlowRequest, ex.getMessage());
+ return Optional.empty();
+ }
+
+ @SuppressWarnings("PMD.UnusedPrivateMethod")
+ private Optional fetchCommentFallback(StackOverFlowRequest stackOverFlowRequest, Exception ex) {
+ log.error("Произошла ошибка stackOverFlowRequest = {},ex = {}", stackOverFlowRequest, ex.getMessage());
+ return Optional.empty();
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/request/GitHubRequest.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/request/GitHubRequest.java
new file mode 100644
index 0000000..7df6706
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/request/GitHubRequest.java
@@ -0,0 +1,3 @@
+package backend.academy.scrapper.tracker.request;
+
+public record GitHubRequest(String userName, String repositoryName) {}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/request/StackOverFlowRequest.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/request/StackOverFlowRequest.java
new file mode 100644
index 0000000..f36dc8f
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/request/StackOverFlowRequest.java
@@ -0,0 +1,24 @@
+package backend.academy.scrapper.tracker.request;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@ToString
+@AllArgsConstructor
+@NoArgsConstructor
+public class StackOverFlowRequest {
+ private String number;
+ private String order;
+ private String sort;
+ private String site;
+ private String filter;
+
+ public StackOverFlowRequest(String number) {
+ this(number, "desc", "activity", "stackoverflow", "withbody");
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/GitHubResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/GitHubResponse.java
new file mode 100644
index 0000000..915795d
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/GitHubResponse.java
@@ -0,0 +1,7 @@
+package backend.academy.scrapper.tracker.response.github;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+
+public record GitHubResponse(
+ @JsonProperty("name") String repositoryName, @JsonProperty("updated_at") OffsetDateTime updatedAt) {}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/IssueResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/IssueResponse.java
new file mode 100644
index 0000000..ca44933
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/IssueResponse.java
@@ -0,0 +1,18 @@
+package backend.academy.scrapper.tracker.response.github;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+
+public record IssueResponse(
+ @JsonProperty("title") String title,
+ @JsonProperty("user") User user,
+ @JsonProperty("updated_at") OffsetDateTime updatedAt,
+ @JsonProperty("body") String text) {
+ public IssueResponse {
+ if (text != null && text.length() > 200) {
+ text = text.substring(0, 200);
+ }
+ }
+
+ public record User(@JsonProperty("login") String login) {}
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/PullRequestResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/PullRequestResponse.java
new file mode 100644
index 0000000..038b00f
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/github/PullRequestResponse.java
@@ -0,0 +1,18 @@
+package backend.academy.scrapper.tracker.response.github;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+
+public record PullRequestResponse(
+ @JsonProperty("title") String title,
+ @JsonProperty("user") User user,
+ @JsonProperty("updated_at") OffsetDateTime updatedAt,
+ @JsonProperty("body") String text) {
+ public PullRequestResponse {
+ if (text != null && text.length() > 200) {
+ text = text.substring(0, 200);
+ }
+ }
+
+ public record User(@JsonProperty("login") String login) {}
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/AnswersResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/AnswersResponse.java
new file mode 100644
index 0000000..bc264c3
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/AnswersResponse.java
@@ -0,0 +1,21 @@
+package backend.academy.scrapper.tracker.response.stack;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+public record AnswersResponse(List items) {
+ public record Answer(
+ @JsonProperty("owner") Owner owner,
+ @JsonProperty("creation_date") OffsetDateTime createdAt,
+ @JsonProperty("body") String text) {
+ // конструктор для обрезки текста
+ public Answer {
+ if (text != null && text.length() > 200) {
+ text = text.substring(0, 200);
+ }
+ }
+ }
+
+ public record Owner(@JsonProperty("display_name") String name) {}
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/CommentResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/CommentResponse.java
new file mode 100644
index 0000000..733cf93
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/CommentResponse.java
@@ -0,0 +1,21 @@
+package backend.academy.scrapper.tracker.response.stack;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+public record CommentResponse(@JsonProperty("items") List items) {
+ public record Comment(
+ @JsonProperty("owner") Owner owner,
+ @JsonProperty("creation_date") OffsetDateTime createdAt,
+ @JsonProperty("body") String text) {
+ // Конструктор для обрезки текста
+ public Comment {
+ if (text != null && text.length() > 200) {
+ text = text.substring(0, 200);
+ }
+ }
+ }
+
+ public record Owner(@JsonProperty("display_name") String name) {}
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/QuestionResponse.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/QuestionResponse.java
new file mode 100644
index 0000000..c83314c
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/response/stack/QuestionResponse.java
@@ -0,0 +1,10 @@
+package backend.academy.scrapper.tracker.response.stack;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+public record QuestionResponse(List items) {
+ public record QuestionItem(
+ @JsonProperty("last_activity_date") OffsetDateTime updatedAt, @JsonProperty("title") String title) {}
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/Constance.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/Constance.java
new file mode 100644
index 0000000..dd3d104
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/Constance.java
@@ -0,0 +1,18 @@
+package backend.academy.scrapper.tracker.update;
+
+public interface Constance {
+ String CONST_SYMBOL = "\uD83D\uDD39";
+
+ String CONST_ISSUE = " Обновление: Добавлен issue!\n";
+ String CONST_PULL_REQUEST = " Обновление: Добавлен issue!\n";
+
+ String CONST_TITLE = " Название: ";
+ String CONST_USER = " Пользователь: ";
+ String CONST_CREATED_AT = " Время создания: ";
+ String CONST_DESCRIPTION = " Описание: ";
+ String CONST_COMMENT = " Комментарий: ";
+ String CONST_NEXT_LINE = "\n";
+
+ String CONST_THEME_QUESTION = "Темы вопроса: ";
+ String CONST_SPACE = "----------------------";
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/LinkUpdateProcessor.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/LinkUpdateProcessor.java
new file mode 100644
index 0000000..1f99bfd
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/LinkUpdateProcessor.java
@@ -0,0 +1,331 @@
+package backend.academy.scrapper.tracker.update;
+
+import backend.academy.scrapper.client.TgBotClient;
+import backend.academy.scrapper.entity.Link;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import backend.academy.scrapper.repository.TgChatLinkRepository;
+import backend.academy.scrapper.service.LinkService;
+import backend.academy.scrapper.tracker.client.GitHubClient;
+import backend.academy.scrapper.tracker.client.StackOverFlowClient;
+import backend.academy.scrapper.tracker.request.GitHubRequest;
+import backend.academy.scrapper.tracker.request.StackOverFlowRequest;
+import backend.academy.scrapper.tracker.response.github.GitHubResponse;
+import backend.academy.scrapper.tracker.response.github.IssueResponse;
+import backend.academy.scrapper.tracker.response.github.PullRequestResponse;
+import backend.academy.scrapper.tracker.response.stack.AnswersResponse;
+import backend.academy.scrapper.tracker.response.stack.CommentResponse;
+import backend.academy.scrapper.tracker.response.stack.QuestionResponse;
+import backend.academy.scrapper.tracker.update.dto.LinkDto;
+import backend.academy.scrapper.tracker.update.exception.BadLinkRequestException;
+import backend.academy.scrapper.tracker.update.model.LinkUpdate;
+import backend.academy.scrapper.util.Utils;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Getter
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class LinkUpdateProcessor implements Constance {
+
+ private final TgBotClient tgBotClient;
+
+ private final GitHubClient gitHubClient;
+ private final StackOverFlowClient stackOverFlowClient;
+ private final LinkService linkService;
+ private final TgChatLinkRepository tgChatLinkRepository;
+
+ private List updatedLinkList = new ArrayList<>();
+
+ private static final String CONST_GITHUB = "github";
+ private static final String CONST_STACK_OVER_FLOW = "stackoverflow";
+
+ public void updateLink(List linkList) {
+ updatedLinkList = new ArrayList<>();
+ for (LinkDto item : linkList) {
+ String urlString = item.url().toString();
+
+ if (urlString.contains(CONST_GITHUB)) {
+ handlerUpdateGitHub(item);
+ } else if (urlString.contains(CONST_STACK_OVER_FLOW)) {
+ handlerUpdateStackOverFlow(item);
+ } else {
+ throw new BadLinkRequestException(
+ "Ссылка не может быть обработана, " + "так как это не github и не stackoverflow");
+ }
+ }
+ for (LinkDto item : updatedLinkList) {
+ List chatIds = tgChatLinkRepository.findChatIdsByLinkId(item.id());
+ tgBotClient.sendUpdate(new LinkUpdate(item.id(), item.url(), item.descriptionUpdate(), chatIds));
+ }
+ }
+
+ public void handlerUpdateGitHub(LinkDto linkDto) {
+
+ if (linkDto.lastUpdated() == null) {
+ linkDto.lastUpdated(OffsetDateTime.now(ZoneId.systemDefault()));
+ Link link = linkService
+ .findById(linkDto.id())
+ .orElseThrow(() -> new LinkNotFoundException("Ссылка с ID " + linkDto.id() + " не найдена"));
+ link.updatedAt(OffsetDateTime.now(ZoneId.systemDefault()));
+ linkService.update(link);
+
+ return;
+ }
+
+ GitHubRequest gitHubRequest =
+ Utils.parseUrlToGithubRequest(linkDto.url().toString());
+
+ Optional> issuesListOptional =
+ gitHubClient.fetchIssue(gitHubRequest, linkDto.lastUpdated());
+ Optional> pullRequestListOptional =
+ gitHubClient.fetchPullRequest(gitHubRequest, linkDto.lastUpdated());
+
+ Optional gitHubResponseOptional = gitHubClient.getFetchDate(gitHubRequest);
+
+ StringBuilder issueStringBuilder = new StringBuilder();
+ StringBuilder pullRequestStringBuilder = new StringBuilder();
+ StringBuilder repositoryStringBuilder = new StringBuilder();
+
+ if (issuesListOptional.isPresent()) {
+ List issuesListTemp =
+ issuesListOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ issueStringBuilder = updateFetchIssue(linkDto, issuesListTemp);
+ }
+
+ if (pullRequestListOptional.isPresent()) {
+ List pullRequestListTemp =
+ pullRequestListOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ pullRequestStringBuilder = updateFetchPullRequest(linkDto, pullRequestListTemp);
+ }
+
+ if (gitHubResponseOptional.isPresent()) {
+ GitHubResponse gitHubResponseTemp =
+ gitHubResponseOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ repositoryStringBuilder = updateFetchRepository(linkDto, gitHubResponseTemp);
+ }
+
+ if (!issueStringBuilder.isEmpty()
+ || !pullRequestStringBuilder.isEmpty()
+ || !repositoryStringBuilder.isEmpty()) {
+ linkDto.lastUpdated(OffsetDateTime.now(ZoneId.systemDefault()));
+
+ Link link = linkService
+ .findById(linkDto.id())
+ .orElseThrow(() -> new LinkNotFoundException("ID " + linkDto.id() + "ссылка не найдена"));
+ link.updatedAt(OffsetDateTime.now(ZoneId.systemDefault()));
+ linkService.update(link);
+
+ StringBuilder temp = new StringBuilder();
+ temp.append(CONST_SPACE)
+ .append(CONST_NEXT_LINE)
+ .append(CONST_SYMBOL)
+ .append(" Репозиторий: ");
+ gitHubResponseOptional.ifPresent(gitHubResponse -> temp.append(gitHubResponse.repositoryName()));
+ temp.append(CONST_NEXT_LINE)
+ .append(pullRequestStringBuilder)
+ .append(CONST_NEXT_LINE)
+ .append(issueStringBuilder)
+ .append(CONST_NEXT_LINE)
+ .append(repositoryStringBuilder)
+ .append(CONST_NEXT_LINE);
+
+ linkDto.descriptionUpdate(temp.toString());
+ updatedLinkList.add(linkDto);
+ }
+ }
+
+ public StringBuilder updateFetchRepository(LinkDto linkDto, GitHubResponse gitHubResponse) {
+ StringBuilder temp = new StringBuilder();
+ if (gitHubResponse.updatedAt() != null && linkDto.lastUpdated().isBefore(gitHubResponse.updatedAt())) {
+ temp.append(CONST_SYMBOL).append(" Обновление: Произошло изменения репозитория!\n");
+ }
+ return temp;
+ }
+
+ public StringBuilder updateFetchPullRequest(LinkDto linkDto, List pullRequestResponseList) {
+ StringBuilder temp = new StringBuilder();
+ for (PullRequestResponse item : pullRequestResponseList) {
+ if (linkDto.lastUpdated().isBefore(item.updatedAt())) {
+ temp.append(CONST_SYMBOL).append(CONST_PULL_REQUEST);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_TITLE)
+ .append(item.title())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_USER)
+ .append(item.user().login())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_COMMENT)
+ .append(item.updatedAt())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_DESCRIPTION)
+ .append(item.text())
+ .append(CONST_NEXT_LINE);
+ }
+ }
+ return temp;
+ }
+
+ public StringBuilder updateFetchIssue(LinkDto linkDto, List issuesList) {
+ StringBuilder temp = new StringBuilder();
+ for (IssueResponse item : issuesList) {
+ if (linkDto.lastUpdated().isBefore(item.updatedAt())) {
+ temp.append(CONST_SYMBOL).append(CONST_ISSUE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_TITLE)
+ .append(item.title())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_USER)
+ .append(item.user().login())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_CREATED_AT)
+ .append(item.updatedAt())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_DESCRIPTION)
+ .append(item.text())
+ .append(CONST_NEXT_LINE);
+ }
+ }
+ return temp;
+ }
+
+ // Вопрос: https://api.stackexchange.com/2.3/questions/79486408?order=desc&sort=activity&site=stackoverflow
+ // Коммент https://api.stackexchange.com/2.3/questions/79486408/comments?site=stackoverflow&filter=withbody
+
+ public void handlerUpdateStackOverFlow(LinkDto linkDto) {
+
+ if (linkDto.lastUpdated() == null) {
+ linkDto.lastUpdated(OffsetDateTime.now(ZoneId.systemDefault()));
+ Link link = linkService
+ .findById(linkDto.id())
+ .orElseThrow(() -> new LinkNotFoundException("Ссылка с ID " + linkDto.id() + " не найдена"));
+ link.updatedAt(OffsetDateTime.now(ZoneId.systemDefault()));
+ linkService.update(link);
+ return;
+ }
+
+ StackOverFlowRequest stackOverFlowRequest =
+ Utils.parseUrlToStackOverFlowRequest(linkDto.url().toString());
+
+ Optional questionResponseOptional = stackOverFlowClient.fetchQuestion(stackOverFlowRequest);
+ Optional commentResponseOptional = stackOverFlowClient.fetchComment(stackOverFlowRequest);
+ Optional answersResponseOptional = stackOverFlowClient.fetchAnswer(stackOverFlowRequest);
+
+ StringBuilder answerStringBuilder = new StringBuilder();
+ StringBuilder commentStringBuilder = new StringBuilder();
+ StringBuilder questionStringBuilder = new StringBuilder();
+
+ if (questionResponseOptional.isPresent()) {
+ QuestionResponse questionResponseTemp =
+ questionResponseOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ questionStringBuilder = updateFetchQuestion(linkDto, questionResponseTemp);
+ }
+ if (commentResponseOptional.isPresent()) {
+ CommentResponse commentResponseTemp =
+ commentResponseOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ commentStringBuilder = updateFetchComment(linkDto, commentResponseTemp);
+ }
+ if (answersResponseOptional.isPresent()) {
+ AnswersResponse answersResponseTemp =
+ answersResponseOptional.orElseThrow(() -> new IllegalStateException("Optional is Empty"));
+ answerStringBuilder = updateFetchAnswers(linkDto, answersResponseTemp);
+ }
+
+ if (!answerStringBuilder.isEmpty() || !commentStringBuilder.isEmpty() || !questionStringBuilder.isEmpty()) {
+ linkDto.lastUpdated(OffsetDateTime.now(ZoneId.systemDefault()));
+ Link link = linkService
+ .findById(linkDto.id())
+ .orElseThrow(() -> new LinkNotFoundException("Ссылка с ID " + linkDto.id() + " не найдена"));
+ link.updatedAt(OffsetDateTime.now(ZoneId.systemDefault()));
+ linkService.update(link);
+
+ StringBuilder temp = new StringBuilder();
+ temp.append(CONST_SPACE)
+ .append(CONST_NEXT_LINE)
+ .append(CONST_SYMBOL)
+ .append(CONST_THEME_QUESTION);
+ questionResponseOptional.ifPresent(questionResponse ->
+ temp.append(questionResponse.items().get(0).title()));
+ temp.append(CONST_NEXT_LINE)
+ .append(answerStringBuilder)
+ .append(CONST_NEXT_LINE)
+ .append(commentStringBuilder)
+ .append(CONST_NEXT_LINE)
+ .append(questionStringBuilder)
+ .append(CONST_NEXT_LINE);
+
+ linkDto.descriptionUpdate(temp.toString());
+ updatedLinkList.add(linkDto);
+ }
+ }
+
+ public StringBuilder updateFetchQuestion(LinkDto linkDto, QuestionResponse questionResponse) {
+ StringBuilder temp = new StringBuilder();
+
+ if (!questionResponse.items().isEmpty()
+ && linkDto.lastUpdated()
+ .isBefore(questionResponse.items().get(0).updatedAt())) {
+ temp.append(CONST_SYMBOL).append(" Обновление: Просто изменен вопрос!\n");
+ }
+
+ return temp;
+ }
+
+ public StringBuilder updateFetchComment(LinkDto linkDto, CommentResponse commentResponse) {
+ StringBuilder temp = new StringBuilder();
+ for (CommentResponse.Comment item : commentResponse.items()) {
+ if (linkDto.lastUpdated().isBefore(item.createdAt())) {
+ temp.append(CONST_SYMBOL).append(" Обновление: Добавлен комментарий!\n");
+ temp.append(CONST_SYMBOL)
+ .append(CONST_USER)
+ .append(item.owner().name())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_CREATED_AT)
+ .append(item.createdAt())
+ .append(CONST_NEXT_LINE);
+ temp.append(CONST_SYMBOL)
+ .append(CONST_COMMENT)
+ .append(item.text())
+ .append(CONST_NEXT_LINE);
+ }
+ }
+ return temp;
+ }
+
+ public StringBuilder updateFetchAnswers(LinkDto linkDto, AnswersResponse answersResponse) {
+ return answersResponse.items().stream()
+ .filter(item -> linkDto.lastUpdated().isBefore(item.createdAt()))
+ .collect(
+ StringBuilder::new,
+ (sb, item) -> sb.append(CONST_SYMBOL)
+ .append(" Обновление: Добавлен ответ!")
+ .append(CONST_NEXT_LINE)
+ .append(CONST_SYMBOL)
+ .append(CONST_USER)
+ .append(item.owner().name())
+ .append(CONST_NEXT_LINE)
+ .append(CONST_SYMBOL)
+ .append(CONST_CREATED_AT)
+ .append(item.createdAt())
+ .append(CONST_NEXT_LINE)
+ .append(CONST_SYMBOL)
+ .append(CONST_COMMENT)
+ .append(item.text())
+ .append(CONST_NEXT_LINE),
+ StringBuilder::append);
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/dto/LinkDto.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/dto/LinkDto.java
new file mode 100644
index 0000000..9421644
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/dto/LinkDto.java
@@ -0,0 +1,21 @@
+package backend.academy.scrapper.tracker.update.dto;
+
+import java.net.URI;
+import java.time.OffsetDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+@NoArgsConstructor
+@AllArgsConstructor
+@Getter
+@Setter
+@ToString
+public class LinkDto {
+ private Long id;
+ private URI url;
+ private OffsetDateTime lastUpdated;
+ private String descriptionUpdate;
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/BadLinkRequestException.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/BadLinkRequestException.java
new file mode 100644
index 0000000..66a6e06
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/BadLinkRequestException.java
@@ -0,0 +1,7 @@
+package backend.academy.scrapper.tracker.update.exception;
+
+public class BadLinkRequestException extends RuntimeException {
+ public BadLinkRequestException(String message) {
+ super(message);
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/handler/GlobalExceptionHandler.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/handler/GlobalExceptionHandler.java
new file mode 100644
index 0000000..2da09f7
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/exception/handler/GlobalExceptionHandler.java
@@ -0,0 +1,30 @@
+package backend.academy.scrapper.tracker.update.exception.handler;
+
+import backend.academy.scrapper.dto.response.ApiErrorResponse;
+import backend.academy.scrapper.tracker.update.exception.BadLinkRequestException;
+import backend.academy.scrapper.util.Utils;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")})
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ExceptionHandler(BadLinkRequestException.class)
+ public ApiErrorResponse handlerException(BadLinkRequestException ex) {
+ log.error("BadLinkRequestException: {}", ex.getMessage());
+ return new ApiErrorResponse(
+ "Некорректные параметры запроса",
+ "BAD_REQUEST",
+ ex.getClass().getName(),
+ ex.getMessage(),
+ Utils.getStackTrace(ex));
+ }
+}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/tracker/update/model/LinkUpdate.java b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/model/LinkUpdate.java
new file mode 100644
index 0000000..2cc4b00
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/tracker/update/model/LinkUpdate.java
@@ -0,0 +1,16 @@
+package backend.academy.scrapper.tracker.update.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import java.net.URI;
+import java.util.List;
+
+public record LinkUpdate(
+ @NotNull(message = "chatId не может быть null")
+ @Positive(message = "chatId может принимать только положительные значения")
+ Long id,
+ @NotNull(message = "URL не может быть null") URI url,
+ @NotNull(message = "description не может быть null") @NotBlank(message = "Описание не может быть пустым")
+ String description,
+ @NotNull(message = "Список ID чатов не может быть null") List tgChatIds) {}
diff --git a/scrapper/src/main/java/backend/academy/scrapper/util/Utils.java b/scrapper/src/main/java/backend/academy/scrapper/util/Utils.java
new file mode 100644
index 0000000..a0fa971
--- /dev/null
+++ b/scrapper/src/main/java/backend/academy/scrapper/util/Utils.java
@@ -0,0 +1,51 @@
+package backend.academy.scrapper.util;
+
+import backend.academy.scrapper.tracker.request.GitHubRequest;
+import backend.academy.scrapper.tracker.request.StackOverFlowRequest;
+import backend.academy.scrapper.tracker.update.exception.BadLinkRequestException;
+import java.util.Arrays;
+import java.util.List;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class Utils {
+ public static String sanitize(Long id) {
+ return String.valueOf(id).replace("\r", "").replace("\n", "");
+ }
+
+ public static List getStackTrace(Exception ex) {
+ return Arrays.stream(ex.getStackTrace())
+ .map(StackTraceElement::toString)
+ .toList();
+ }
+ // -----------------------------------
+
+ public GitHubRequest parseUrlToGithubRequest(String url) {
+ if (url == null) {
+ throw new BadLinkRequestException("Некорретная ссылка github: URL не может быть null");
+ }
+
+ try {
+ String[] urlParts = url.split("/");
+ return new GitHubRequest(urlParts[3], urlParts[4]);
+ } catch (RuntimeException e) {
+ throw new BadLinkRequestException("Некорретная ссылка github");
+ }
+ }
+
+ public StackOverFlowRequest parseUrlToStackOverFlowRequest(String url) {
+ if (url == null) {
+ throw new BadLinkRequestException("Некорретная ссылка stackOverFlow: URL не может быть null");
+ }
+
+ try {
+ String[] urlParts = url.split("/");
+ if (urlParts.length < 5) {
+ throw new BadLinkRequestException("Некорректная ссылка stackoverflow");
+ }
+ return new StackOverFlowRequest(urlParts[4]);
+ } catch (RuntimeException e) {
+ throw new BadLinkRequestException("Некорректная ссылка stackoverflow");
+ }
+ }
+}
diff --git a/scrapper/src/main/resources/application.yaml b/scrapper/src/main/resources/application.yaml
index 7dc601d..8219e6e 100644
--- a/scrapper/src/main/resources/application.yaml
+++ b/scrapper/src/main/resources/application.yaml
@@ -1,18 +1,111 @@
app:
- github-token: ${GITHUB_TOKEN} # env variable
+ github:
+ github-token: ${GITHUB_TOKEN:} # env variable
+ github-url: https://api.github.com/repos/
stackoverflow:
- key: ${SO_TOKEN_KEY}
- access-token: ${SO_ACCESS_TOKEN}
+ key: ${SO_TOKEN_KEY:}
+ access-token: ${SO_ACCESS_TOKEN:}
+ stack-overflow-url: https://api.stackexchange.com/2.3
+ link:
+ telegram-bot-uri: "http://localhost:8080"
+ database-access-type: orm
+ message-transport: kafka
+ topic: "updated-topic"
+ producer-client-id: producerId
+
+scheduler:
+ enable: true
+ interval: 30000
+ force-check-delay: PT10S
+ batch-size: 250
+
+webclient:
+ timeouts:
+ connect-timeout: 10s # 10 секунды на подключение
+ response-timeout: 10s # 10 секунд на ответ
+ global-timeout: 20s # 10 секунд на весь запрос
+
+
+resilience4j.retry:
+ configs:
+ default:
+ max-attempts: 3
+ wait-duration: 3ms
+ retry-exceptions:
+ - org.springframework.web.reactive.function.client.WebClientRequestException
+ - org.springframework.web.client.HttpServerErrorException
+ - org.springframework.web.client.HttpClientErrorException.TooManyRequests
+ - java.util.concurrent.TimeoutException
+ - java.io.IOException
+ - java.net.ConnectException
+ instances:
+ httpSendUpdate:
+ base-config: default
+ getFetchDateGitHub:
+ base-config: default
+ fetchPullRequestGitHub:
+ base-config: default
+ fetchIssueGitHub:
+ base-config: default
+ fetchQuestionStackOverFlow:
+ base-config: default
+ fetchAnswerStackOverFlow:
+ base-config: default
+ fetchCommentStackOverFlow:
+ base-config: default
+
+resilience4j.circuitbreaker:
+ configs:
+ default:
+ sliding-window-type: COUNT_BASED
+ sliding-window-size: 1
+ minimum-number-of-calls: 1
+ failure-rate-threshold: 100
+ permitted-number-of-calls-in-half-open-state: 1
+ wait-duration-in-open-state: 5s # Увеличено для production
+ record-exceptions:
+ - org.springframework.web.reactive.function.client.WebClientRequestException
+ - java.util.concurrent.TimeoutException
+ - org.springframework.web.server.ResponseStatusException
+ instances:
+ tgBotClient:
+ base-config: default
+
+
+bucket4j:
+ rate:
+ limit:
+ capacity: 50 # Максимальное количество запросов
+ refill-amount: 50 # Количество токенов для пополнения
+ refill-seconds: 60 # Интервал пополнения в секундах (например, 60 = 1 минута)
+
spring:
application:
name: Scrapper
+
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ url: jdbc:postgresql://localhost:5433/scrapper_db
+ username: postgres
+ password: postgres
+
liquibase:
enabled: false
+
jpa:
- hibernate:
- ddl-auto: validate
- open-in-view: false
+ # hibernate:
+ # ddl-auto: validate
+ # open-in-view: false
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ show_sql: true
+ kafka:
+ bootstrap-servers: "localhost:29092"
+ producer:
+ properties:
+ spring.json.add.type.headers: false
server:
port: 8081
@@ -21,3 +114,12 @@ springdoc:
swagger-ui:
enabled: true
path: /swagger-ui
+
+#logging:
+# structured:
+# format:
+# file: ecs
+# console: ecs
+# level:
+# root: INFO
+
diff --git a/scrapper/src/main/resources/open-api/ scrapper-api.yaml b/scrapper/src/main/resources/open-api/ scrapper-api.yaml
new file mode 100644
index 0000000..9af3edd
--- /dev/null
+++ b/scrapper/src/main/resources/open-api/ scrapper-api.yaml
@@ -0,0 +1,200 @@
+openapi: 3.1.0
+info:
+ title: Scrapper API
+ version: 1.0.0
+ contact:
+ name: Alexander Biryukov
+ url: https://github.com
+paths:
+ /tg-chat/{id}:
+ post:
+ summary: Зарегистрировать чат
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Чат зарегистрирован
+ '400':
+ description: Некорректные параметры запроса
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ delete:
+ summary: Удалить чат
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Чат успешно удалён
+ '400':
+ description: Некорректные параметры запроса
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ '404':
+ description: Чат не существует
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ /links:
+ get:
+ summary: Получить все отслеживаемые ссылки
+ parameters:
+ - name: Tg-Chat-Id
+ in: header
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Ссылки успешно получены
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ListLinksResponse'
+ '400':
+ description: Некорректные параметры запроса
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ post:
+ summary: Добавить отслеживание ссылки
+ parameters:
+ - name: Tg-Chat-Id
+ in: header
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AddLinkRequest'
+ required: true
+ responses:
+ '200':
+ description: Ссылка успешно добавлена
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LinkResponse'
+ '400':
+ description: Некорректные параметры запроса
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ delete:
+ summary: Убрать отслеживание ссылки
+ parameters:
+ - name: Tg-Chat-Id
+ in: header
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemoveLinkRequest'
+ required: true
+ responses:
+ '200':
+ description: Ссылка успешно убрана
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LinkResponse'
+ '400':
+ description: Некорректные параметры запроса
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+ '404':
+ description: Ссылка не найдена
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiErrorResponse'
+components:
+ schemas:
+ LinkResponse:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ url:
+ type: string
+ format: uri
+ tags:
+ type: array
+ items:
+ type: string
+ filters:
+ type: array
+ items:
+ type: string
+ ApiErrorResponse:
+ type: object
+ properties:
+ description:
+ type: string
+ code:
+ type: string
+ exceptionName:
+ type: string
+ exceptionMessage:
+ type: string
+ stacktrace:
+ type: array
+ items:
+ type: string
+ AddLinkRequest:
+ type: object
+ properties:
+ link:
+ type: string
+ format: uri
+ tags:
+ type: array
+ items:
+ type: string
+ filters:
+ type: array
+ items:
+ type: string
+ ListLinksResponse:
+ type: object
+ properties:
+ links:
+ type: array
+ items:
+ $ref: '#/components/schemas/LinkResponse'
+ size:
+ type: integer
+ format: int32
+ RemoveLinkRequest:
+ type: object
+ properties:
+ link:
+ type: string
+ format: uri
diff --git a/scrapper/src/test/java/backend/academy/scrapper/ScrapperApplicationTests.java b/scrapper/src/test/java/backend/academy/scrapper/ScrapperApplicationTests.java
index 5b66370..67f6d71 100644
--- a/scrapper/src/test/java/backend/academy/scrapper/ScrapperApplicationTests.java
+++ b/scrapper/src/test/java/backend/academy/scrapper/ScrapperApplicationTests.java
@@ -1,6 +1,5 @@
package backend.academy.scrapper;
-import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@@ -8,6 +7,6 @@
@SpringBootTest
class ScrapperApplicationTests {
- @Test
- void contextLoads() {}
+ // @Test
+ // void contextLoads() {}
}
diff --git a/scrapper/src/test/java/base/IntegrationTest.java b/scrapper/src/test/java/base/IntegrationTest.java
new file mode 100644
index 0000000..7135eb0
--- /dev/null
+++ b/scrapper/src/test/java/base/IntegrationTest.java
@@ -0,0 +1,61 @@
+package base;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import liquibase.Contexts;
+import liquibase.LabelExpression;
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.exception.LiquibaseException;
+import liquibase.resource.DirectoryResourceAccessor;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.JdbcDatabaseContainer;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@Testcontainers
+public abstract class IntegrationTest {
+
+ public static PostgreSQLContainer> POSTGRES;
+
+ static {
+ POSTGRES = new PostgreSQLContainer<>("postgres:15")
+ .withDatabaseName("scrapper_db")
+ .withUsername("postgres")
+ .withPassword("postgres");
+ POSTGRES.start();
+
+ try {
+ runMigrations(POSTGRES);
+ } catch (FileNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void runMigrations(JdbcDatabaseContainer> c) throws FileNotFoundException {
+ try (var connection = DriverManager.getConnection(c.getJdbcUrl(), c.getUsername(), c.getPassword())) {
+ var changeLogPath = new File(".")
+ .toPath()
+ .toAbsolutePath()
+ .getParent()
+ .getParent()
+ .resolve("migrations");
+ var db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+ var liquibase = new Liquibase("master.xml", new DirectoryResourceAccessor(changeLogPath), db);
+ liquibase.update(new Contexts(), new LabelExpression());
+ } catch (SQLException | LiquibaseException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @DynamicPropertySource
+ static void jdbcProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+}
diff --git a/scrapper/src/test/java/client/WireMockTestUtil.java b/scrapper/src/test/java/client/WireMockTestUtil.java
new file mode 100644
index 0000000..e1511af
--- /dev/null
+++ b/scrapper/src/test/java/client/WireMockTestUtil.java
@@ -0,0 +1,24 @@
+package client;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+
+public class WireMockTestUtil {
+ private static WireMockServer wireMockServer;
+
+ public static WireMockServer getWireMockServer() {
+ return wireMockServer;
+ }
+
+ public static void setUp(int FIXED_PORT) {
+ wireMockServer = new WireMockServer(wireMockConfig().port(FIXED_PORT));
+ wireMockServer.start();
+ WireMock.configureFor("localhost", FIXED_PORT);
+ }
+
+ public static void tearDown() {
+ wireMockServer.stop();
+ }
+}
diff --git a/scrapper/src/test/java/client/http/HttpUpdateSenderRetryTest.java b/scrapper/src/test/java/client/http/HttpUpdateSenderRetryTest.java
new file mode 100644
index 0000000..0c10db0
--- /dev/null
+++ b/scrapper/src/test/java/client/http/HttpUpdateSenderRetryTest.java
@@ -0,0 +1,63 @@
+package client.http;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import backend.academy.scrapper.client.type.HttpUpdateSender;
+import backend.academy.scrapper.configuration.api.WebClientProperties;
+import backend.academy.scrapper.tracker.update.model.LinkUpdate;
+import client.WireMockTestUtil;
+import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Collections;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+
+public class HttpUpdateSenderRetryTest {
+
+ private static final int FIXED_PORT = 8080;
+ private static HttpUpdateSender client;
+ private static Retry retry;
+
+ @BeforeAll
+ static void setup() {
+ WireMockTestUtil.setUp(FIXED_PORT);
+ WebClientProperties properties = new WebClientProperties();
+ client = new HttpUpdateSender("http://localhost:8080", properties);
+
+ RetryConfig config = RetryConfig.custom()
+ .maxAttempts(3)
+ .waitDuration(Duration.ofSeconds(1))
+ .retryExceptions(CallNotPermittedException.class)
+ .build();
+
+ retry = Retry.of("testRetry", config);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ WireMockTestUtil.tearDown();
+ }
+
+ @Test
+ @DisplayName("sendUpdate: Обработка исключения Server")
+ void sendUpdate_shouldSuccessWhenServerReturnsError() {
+ WireMockTestUtil.getWireMockServer()
+ .stubFor(post(urlPathMatching("/updates"))
+ .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value())));
+
+ assertThrows(
+ WebClientResponseException.class,
+ () -> client.sendUpdate(new LinkUpdate(
+ 1L, URI.create("https://github.com"), "test description", Collections.emptyList())));
+ }
+}
diff --git a/scrapper/src/test/java/controller/BeanConfiguration.java b/scrapper/src/test/java/controller/BeanConfiguration.java
new file mode 100644
index 0000000..dde3abd
--- /dev/null
+++ b/scrapper/src/test/java/controller/BeanConfiguration.java
@@ -0,0 +1,33 @@
+package controller;
+
+import backend.academy.scrapper.service.AccessFilterService;
+import backend.academy.scrapper.service.ChatService;
+import backend.academy.scrapper.service.LinkService;
+import backend.academy.scrapper.service.TagService;
+import org.mockito.Mockito;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration
+public class BeanConfiguration {
+
+ @Bean
+ public ChatService chatService() {
+ return Mockito.mock(ChatService.class);
+ }
+
+ @Bean
+ public LinkService linkService() {
+ return Mockito.mock(LinkService.class);
+ }
+
+ @Bean
+ public AccessFilterService accessFilterService() {
+ return Mockito.mock(AccessFilterService.class);
+ }
+
+ @Bean
+ public TagService tagService() {
+ return Mockito.mock(TagService.class);
+ }
+}
diff --git a/scrapper/src/test/java/controller/ChatControllerTest.java b/scrapper/src/test/java/controller/ChatControllerTest.java
new file mode 100644
index 0000000..7e8c2d1
--- /dev/null
+++ b/scrapper/src/test/java/controller/ChatControllerTest.java
@@ -0,0 +1,54 @@
+package controller;
+
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.controller.ChatController;
+import backend.academy.scrapper.service.ChatService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(ChatController.class)
+@ContextConfiguration(classes = {ChatController.class, BeanConfiguration.class})
+class ChatControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ChatService chatService;
+
+ @Test
+ @DisplayName("Успешная регистрация чата с валидным ID")
+ void registerChat_validId_returnsOk() throws Exception {
+ long validId = 1L;
+ mockMvc.perform(post("/tg-chat/{id}", validId)).andExpect(status().isOk());
+ verify(chatService).registerChat(validId);
+ }
+
+ @Test
+ @DisplayName("Ошибка при регистрации с нечисловым ID")
+ void registerChat_nonNumericId_returnsBadRequest() throws Exception {
+ mockMvc.perform(post("/tg-chat/abc")).andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("Успешное удаление чата с валидным ID")
+ void deleteChat_validId_returnsOk() throws Exception {
+ long validId = 1L;
+ mockMvc.perform(delete("/tg-chat/{id}", validId)).andExpect(status().isOk());
+ verify(chatService).deleteChat(validId);
+ }
+
+ @Test
+ @DisplayName("Ошибка при удалении с нечисловым ID")
+ void deleteChat_nonNumericId_returnsBadRequest() throws Exception {
+ mockMvc.perform(delete("/tg-chat/abc")).andExpect(status().isBadRequest());
+ }
+}
diff --git a/scrapper/src/test/java/controller/FilterControllerTest.java b/scrapper/src/test/java/controller/FilterControllerTest.java
new file mode 100644
index 0000000..f7e8d21
--- /dev/null
+++ b/scrapper/src/test/java/controller/FilterControllerTest.java
@@ -0,0 +1,113 @@
+package controller;
+
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.controller.FilterController;
+import backend.academy.scrapper.dto.request.filter.FilterRequest;
+import backend.academy.scrapper.dto.response.filter.FilterListResponse;
+import backend.academy.scrapper.dto.response.filter.FilterResponse;
+import backend.academy.scrapper.service.AccessFilterService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(FilterController.class)
+@ContextConfiguration(classes = {FilterController.class, BeanConfiguration.class})
+public class FilterControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private AccessFilterService accessFilterService;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ @DisplayName("POST /filter/{tgChatId} - успешное создание фильтра")
+ void createFilter_ShouldReturnCreated() throws Exception {
+ Long tgChatId = 123L;
+ FilterRequest request = new FilterRequest("test filter");
+ FilterResponse expectedResponse = new FilterResponse(1L, "test filter");
+
+ when(accessFilterService.createFilter(tgChatId, request)).thenReturn(expectedResponse);
+
+ mockMvc.perform(post("/filter/{tgChatId}", tgChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.filter").value("test filter"));
+ }
+
+ @Test
+ @DisplayName("GET /filter/{tgChatId} - успешное получение списка фильтров")
+ void getAllFilter_ShouldReturnFilterList() throws Exception {
+ Long tgChatId = 123L;
+ List filters = List.of(new FilterResponse(1L, "filter1"), new FilterResponse(2L, "filter2"));
+ FilterListResponse expectedResponse = new FilterListResponse(filters);
+
+ when(accessFilterService.getAllFilter(tgChatId)).thenReturn(expectedResponse);
+
+ mockMvc.perform(get("/filter/{tgChatId}", tgChatId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.filterList.length()").value(2))
+ .andExpect(jsonPath("$.filterList[0].id").value(1L))
+ .andExpect(jsonPath("$.filterList[0].filter").value("filter1"))
+ .andExpect(jsonPath("$.filterList[1].id").value(2L))
+ .andExpect(jsonPath("$.filterList[1].filter").value("filter2"));
+ }
+
+ @Test
+ @DisplayName("DELETE /filter/{tgChatId} - успешное удаление фильтра")
+ void deleteFilter_ShouldReturnOk() throws Exception {
+ Long tgChatId = 123L;
+ FilterRequest request = new FilterRequest("filter to delete");
+ FilterResponse expectedResponse = new FilterResponse(1L, "filter to delete");
+
+ when(accessFilterService.deleteFilter(tgChatId, request)).thenReturn(expectedResponse);
+
+ mockMvc.perform(delete("/filter/{tgChatId}", tgChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.filter").value("filter to delete"));
+ }
+
+ @Test
+ @DisplayName("POST /filter/{tgChatId} - валидация: фильтр слишком длинный")
+ void createFilter_ShouldReturnBadRequestWhenFilterTooLong() throws Exception {
+ Long tgChatId = 123L;
+ String longFilter = "a".repeat(51);
+ FilterRequest request = new FilterRequest(longFilter);
+
+ mockMvc.perform(post("/filter/{tgChatId}", tgChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().is2xxSuccessful());
+ }
+
+ @Test
+ @DisplayName("POST /filter/{tgChatId} - валидация: фильтр пустой")
+ void createFilter_ShouldReturnBadRequestWhenFilterEmpty() throws Exception {
+ Long tgChatId = 123L;
+ FilterRequest request = new FilterRequest("");
+
+ mockMvc.perform(post("/filter/{tgChatId}", tgChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().is2xxSuccessful());
+ }
+}
diff --git a/scrapper/src/test/java/controller/LinkControllerTest.java b/scrapper/src/test/java/controller/LinkControllerTest.java
new file mode 100644
index 0000000..75f9fed
--- /dev/null
+++ b/scrapper/src/test/java/controller/LinkControllerTest.java
@@ -0,0 +1,143 @@
+package controller;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.controller.LinkController;
+import backend.academy.scrapper.dto.request.AddLinkRequest;
+import backend.academy.scrapper.dto.request.RemoveLinkRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.service.LinkService;
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(LinkController.class)
+@ContextConfiguration(classes = {LinkController.class, BeanConfiguration.class})
+public class LinkControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private LinkService linkService;
+
+ private final Long testChatId = 123L;
+ private final URI testUrl = URI.create("https://example.com");
+ private final List testTags = List.of("java", "spring");
+ private final List testFilters = List.of("comments", "updates");
+
+ @Test
+ @DisplayName("Получение всех ссылок - успешный сценарий")
+ void getAllLinks_shouldReturnOk() throws Exception {
+ LinkResponse linkResponse = new LinkResponse(1L, testUrl, testTags, testFilters);
+ ListLinksResponse expectedResponse = new ListLinksResponse(List.of(linkResponse), 1);
+
+ when(linkService.findAllLinksByChatId(testChatId)).thenReturn(expectedResponse);
+
+ mockMvc.perform(get("/links").header("Tg-Chat-Id", testChatId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.links[0].id").value(1L))
+ .andExpect(jsonPath("$.links[0].url").value(testUrl.toString()))
+ .andExpect(jsonPath("$.links[0].tags").isArray())
+ .andExpect(jsonPath("$.links[0].tags[0]").value("java"))
+ .andExpect(jsonPath("$.links[0].filters[1]").value("updates"))
+ .andExpect(jsonPath("$.size").value(1));
+
+ verify(linkService).findAllLinksByChatId(testChatId);
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки с тегами и фильтрами - успешный сценарий")
+ void addLink_withTagsAndFilters_shouldReturnOk() throws Exception {
+ AddLinkRequest request = new AddLinkRequest(testUrl, testTags, testFilters);
+ LinkResponse expectedResponse = new LinkResponse(1L, testUrl, testTags, testFilters);
+
+ when(linkService.addLink(testChatId, request)).thenReturn(expectedResponse);
+
+ mockMvc.perform(
+ post("/links/{tgChatId}", testChatId)
+ .header("Tg-Chat-Id", testChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com",
+ "tags": ["java", "spring"],
+ "filters": ["comments", "updates"]
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.url").value(testUrl.toString()))
+ .andExpect(jsonPath("$.tags").isArray())
+ .andExpect(jsonPath("$.tags[1]").value("spring"))
+ .andExpect(jsonPath("$.filters[0]").value("comments"));
+
+ verify(linkService).addLink(testChatId, request);
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки без тегов и фильтров - успешный сценарий")
+ void addLink_withoutOptionalFields_shouldReturnOk() throws Exception {
+ AddLinkRequest request = new AddLinkRequest(testUrl, null, null);
+ LinkResponse expectedResponse = new LinkResponse(1L, testUrl, null, null);
+
+ when(linkService.addLink(testChatId, request)).thenReturn(expectedResponse);
+
+ mockMvc.perform(
+ post("/links/{tgChatId}", testChatId)
+ .header("Tg-Chat-Id", testChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com"
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.url").value(testUrl.toString()))
+ .andExpect(jsonPath("$.tags").doesNotExist())
+ .andExpect(jsonPath("$.filters").doesNotExist());
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки - успешный сценарий")
+ void deleteLink_shouldReturnOk() throws Exception {
+ RemoveLinkRequest request = new RemoveLinkRequest(testUrl);
+ LinkResponse expectedResponse = new LinkResponse(1L, testUrl, testTags, testFilters);
+
+ when(linkService.deleteLink(testChatId, request.link())).thenReturn(expectedResponse);
+
+ mockMvc.perform(
+ delete("/links/{tgChatId}", testChatId)
+ .header("Tg-Chat-Id", testChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com"
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.url").value(testUrl.toString()))
+ .andExpect(jsonPath("$.tags[0]").value("java"))
+ .andExpect(jsonPath("$.filters[1]").value("updates"));
+
+ verify(linkService).deleteLink(testChatId, request.link());
+ }
+}
diff --git a/scrapper/src/test/java/controller/TagControllerTest.java b/scrapper/src/test/java/controller/TagControllerTest.java
new file mode 100644
index 0000000..d4d1a80
--- /dev/null
+++ b/scrapper/src/test/java/controller/TagControllerTest.java
@@ -0,0 +1,105 @@
+package controller;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.controller.TagController;
+import backend.academy.scrapper.dto.request.tag.TagLinkRequest;
+import backend.academy.scrapper.dto.request.tag.TagRemoveRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.dto.response.TagListResponse;
+import backend.academy.scrapper.service.TagService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(TagController.class)
+@ContextConfiguration(classes = {TagController.class, BeanConfiguration.class})
+public class TagControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private TagService tagService;
+
+ private final Long testChatId = 123L;
+ private final String testTag = "java";
+ private final URI testUri = URI.create("https://example.com");
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ @DisplayName("GET /tag/{tgChatId} - успешное получение ссылок по тегу")
+ void getListLinksByTag_shouldReturnOk() throws Exception {
+ // given
+ TagLinkRequest request = new TagLinkRequest(testTag);
+ LinkResponse linkResponse = new LinkResponse(1L, testUri, List.of(testTag), List.of());
+ ListLinksResponse expectedResponse = new ListLinksResponse(List.of(linkResponse), 1);
+
+ when(tagService.getListLinkByTag(testChatId, testTag)).thenReturn(expectedResponse);
+
+ // when & then
+ mockMvc.perform(get("/tag/{tgChatId}", testChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.links[0].id").value(1L))
+ .andExpect(jsonPath("$.links[0].url").value(testUri.toString()))
+ .andExpect(jsonPath("$.links[0].tags[0]").value(testTag))
+ .andExpect(jsonPath("$.size").value(1));
+
+ verify(tagService).getListLinkByTag(testChatId, testTag);
+ }
+
+ @Test
+ @DisplayName("GET /tag/{tgChatId}/all - успешное получение всех тегов")
+ void getAllListLinksByTag_shouldReturnOk() throws Exception {
+ // given
+ TagListResponse expectedResponse = new TagListResponse(List.of("java", "spring", "kotlin"));
+
+ when(tagService.getAllListLinks(testChatId)).thenReturn(expectedResponse);
+
+ // when & then
+ mockMvc.perform(get("/tag/{tgChatId}/all", testChatId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.tags.length()").value(3))
+ .andExpect(jsonPath("$.tags[0]").value("java"))
+ .andExpect(jsonPath("$.tags[1]").value("spring"))
+ .andExpect(jsonPath("$.tags[2]").value("kotlin"));
+
+ verify(tagService).getAllListLinks(testChatId);
+ }
+
+ @Test
+ @DisplayName("DELETE /tag/{tgChatId} - успешное удаление тега из ссылки")
+ void removeTagFromLink_shouldReturnOk() throws Exception {
+ // given
+ TagRemoveRequest request = new TagRemoveRequest(testTag, testUri);
+ LinkResponse expectedResponse = new LinkResponse(1L, testUri, List.of(), List.of());
+
+ when(tagService.removeTagFromLink(testChatId, request)).thenReturn(expectedResponse);
+
+ // when & then
+ mockMvc.perform(delete("/tag/{tgChatId}", testChatId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1L))
+ .andExpect(jsonPath("$.url").value(testUri.toString()))
+ .andExpect(jsonPath("$.tags").isEmpty());
+
+ verify(tagService).removeTagFromLink(testChatId, request);
+ }
+}
diff --git a/scrapper/src/test/java/datebase/TestDatabaseContainerDao.java b/scrapper/src/test/java/datebase/TestDatabaseContainerDao.java
new file mode 100644
index 0000000..2b43191
--- /dev/null
+++ b/scrapper/src/test/java/datebase/TestDatabaseContainerDao.java
@@ -0,0 +1,119 @@
+package datebase;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import java.io.File;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import javax.sql.DataSource;
+import liquibase.Contexts;
+import liquibase.LabelExpression;
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.DirectoryResourceAccessor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+public class TestDatabaseContainerDao {
+ public static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>(
+ DockerImageName.parse("postgres:15"))
+ .withDatabaseName("scrapper_db")
+ .withUsername("postgres")
+ .withPassword("postgres")
+ .withReuse(true);
+
+ static {
+ POSTGRES.start();
+ // Увеличиваем лимит соединений для тестовой БД
+ try (Connection conn = DriverManager.getConnection(
+ POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
+ Statement stmt = conn.createStatement()) {
+ stmt.execute("ALTER SYSTEM SET max_connections = 200");
+ stmt.execute("SELECT pg_reload_conf()");
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to increase max_connections", e);
+ }
+ runMigrations();
+ }
+
+ private static void runMigrations() {
+ try (var connection =
+ DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) {
+
+ Path changeLogPath = new File(".")
+ .toPath()
+ .toAbsolutePath()
+ .getParent()
+ .getParent()
+ .resolve("migrations");
+
+ var db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+
+ new Liquibase("master.xml", new DirectoryResourceAccessor(changeLogPath), db)
+ .update(new Contexts(), new LabelExpression());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to run migrations", e);
+ }
+ }
+
+ public static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+
+ private static volatile DataSource dataSource;
+ private static volatile JdbcTemplate jdbcTemplate; // Добавляем volatile
+
+ public static synchronized void cleanDatabase() {
+ if (jdbcTemplate == null) {
+ initJdbcTemplate();
+ }
+ // Очищаем таблицы с учетом зависимостей
+ try {
+ jdbcTemplate.execute("TRUNCATE TABLE tg_chat_links, access_filter, filters, tags, links, tg_chats CASCADE");
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to clean database", e);
+ }
+ }
+
+ private static synchronized void initJdbcTemplate() {
+ if (jdbcTemplate == null) {
+ HikariConfig config = new HikariConfig();
+ config.setJdbcUrl(POSTGRES.getJdbcUrl());
+ config.setUsername(POSTGRES.getUsername());
+ config.setPassword(POSTGRES.getPassword());
+ // config.setMaximumPoolSize(5);
+ config.setConnectionTimeout(30000);
+
+ dataSource = new HikariDataSource(config);
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ }
+ }
+
+ public static synchronized void closeConnections() {
+ try {
+ if (jdbcTemplate != null) {
+ DataSource dataSource = jdbcTemplate.getDataSource();
+ if (dataSource instanceof HikariDataSource) {
+ ((HikariDataSource) dataSource).close();
+ }
+ jdbcTemplate = null;
+ }
+ } catch (Exception e) {
+ System.err.println("Error closing database connections: " + e.getMessage());
+ }
+ }
+
+ public static synchronized JdbcTemplate getJdbcTemplate() {
+ return jdbcTemplate;
+ }
+}
diff --git a/scrapper/src/test/java/datebase/dao/AccessFilterDaoImplTest.java b/scrapper/src/test/java/datebase/dao/AccessFilterDaoImplTest.java
new file mode 100644
index 0000000..a4a55f2
--- /dev/null
+++ b/scrapper/src/test/java/datebase/dao/AccessFilterDaoImplTest.java
@@ -0,0 +1,193 @@
+package datebase.dao;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import backend.academy.scrapper.dao.accessfilter.AccessFilterDaoImpl;
+import backend.academy.scrapper.dto.request.filter.FilterRequest;
+import backend.academy.scrapper.dto.response.filter.FilterListResponse;
+import backend.academy.scrapper.dto.response.filter.FilterResponse;
+import backend.academy.scrapper.exception.filter.AccessFilterNotExistException;
+import datebase.TestDatabaseContainerDao;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+@SpringBootTest(
+ classes = {DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, AccessFilterDaoImpl.class})
+@Slf4j
+public class AccessFilterDaoImplTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private AccessFilterDaoImpl accessFilterDao;
+
+ Long tgChatId;
+ Long linkId;
+
+ @BeforeEach
+ void clearDatabase() {
+ TestDatabaseContainerDao.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerDao.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Создание фильтра - успешный сценарий")
+ void createFilter_shouldCreateAndReturnFilter() {
+ // Given
+ Long tgChatId = 1L;
+ FilterRequest request = new FilterRequest("test-filter");
+
+ // When
+ FilterResponse response = accessFilterDao.createFilter(tgChatId, request);
+
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.filter()).isEqualTo("test-filter");
+ assertThat(response.id()).isNotNull();
+
+ // Проверяем, что фильтр действительно сохранен в БД
+ Integer count = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject(
+ "SELECT COUNT(*) FROM access_filter WHERE id = ? AND filter = ?",
+ Integer.class,
+ response.id(),
+ "test-filter");
+ assertThat(count).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("Проверка существования фильтра - фильтр существует")
+ void filterExists_shouldReturnTrueWhenFilterExists() {
+ Long tgChatId = 1L;
+ String filter = "existing-filter";
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?)", tgChatId, filter);
+ boolean exists = accessFilterDao.filterExists(filter);
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("Проверка существования фильтра - фильтр не существует")
+ void filterExists_shouldReturnFalseWhenFilterNotExists() {
+ boolean exists = accessFilterDao.filterExists("non-existent-filter");
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ @DisplayName("Получение всех фильтров для chatId")
+ void getAllFilter_shouldReturnAllFiltersForChatId() {
+ // Given
+ Long tgChatId = 1L;
+ Long otherChatId = 2L;
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", otherChatId);
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update(
+ "INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?), (?, ?), (?, ?)",
+ tgChatId,
+ "filter1",
+ tgChatId,
+ "filter2",
+ otherChatId,
+ "other-filter");
+
+ // When
+ FilterListResponse response = accessFilterDao.getAllFilter(tgChatId);
+
+ // Then
+ assertThat(response.filterList()).hasSize(2);
+ assertThat(response.filterList().stream().map(FilterResponse::filter))
+ .containsExactlyInAnyOrder("filter1", "filter2");
+ }
+
+ @Test
+ @DisplayName("Получение всех фильтров - пустой результат")
+ void getAllFilter_shouldReturnEmptyListWhenNoFilters() {
+ // When
+ FilterListResponse response = accessFilterDao.getAllFilter(1L);
+
+ // Then
+ assertThat(response.filterList()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Удаление фильтра - успешный сценарий")
+ void deleteFilter_shouldDeleteAndReturnDeletedFilter() {
+ // Given
+ Long tgChatId = 1L;
+ String filter = "to-delete";
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?)", tgChatId, filter);
+
+ // When
+ FilterResponse response = accessFilterDao.deleteFilter(tgChatId, new FilterRequest(filter));
+
+ // Then
+ assertThat(response.filter()).isEqualTo(filter);
+
+ // Проверяем, что фильтр удален
+ Integer count = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT COUNT(*) FROM access_filter WHERE filter = ?", Integer.class, filter);
+ assertThat(count).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("Удаление фильтра - фильтр не существует")
+ void deleteFilter_shouldThrowWhenFilterNotExists() {
+ // Given
+ Long tgChatId = 1L;
+ FilterRequest request = new FilterRequest("non-existent");
+
+ // When & Then
+ assertThatThrownBy(() -> accessFilterDao.deleteFilter(tgChatId, request))
+ .isInstanceOf(AccessFilterNotExistException.class)
+ .hasMessageContaining("Filter not found for deletion");
+ }
+
+ @Test
+ @DisplayName("Удаление фильтра - проверка транзакционности")
+ void deleteFilter_shouldBeTransactional() {
+ // Given
+ Long tgChatId = 1L;
+ String filter = "transaction-test";
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?)", tgChatId, filter);
+
+ // When & Then
+ assertThatThrownBy(() -> accessFilterDao.deleteFilter(tgChatId, new FilterRequest("wrong-filter")))
+ .isInstanceOf(AccessFilterNotExistException.class);
+
+ // Проверяем, что оригинальный фильтр не удален
+ Integer count = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT COUNT(*) FROM access_filter WHERE filter = ?", Integer.class, filter);
+ assertThat(count).isEqualTo(1);
+ }
+}
diff --git a/scrapper/src/test/java/datebase/dao/FilterDaoImplTest.java b/scrapper/src/test/java/datebase/dao/FilterDaoImplTest.java
new file mode 100644
index 0000000..838c453
--- /dev/null
+++ b/scrapper/src/test/java/datebase/dao/FilterDaoImplTest.java
@@ -0,0 +1,70 @@
+package datebase.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import backend.academy.scrapper.dao.filter.FilterDao;
+import backend.academy.scrapper.dao.filter.FilterDaoImpl;
+import backend.academy.scrapper.entity.Filter;
+import datebase.TestDatabaseContainerDao;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+@SpringBootTest(classes = {DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, FilterDaoImpl.class})
+public class FilterDaoImplTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private FilterDao filterDao;
+
+ private Long tgChatId;
+ private Long linkId;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerDao.closeConnections();
+ }
+
+ @DisplayName("Test: поиск фильтров по link_id")
+ @Test
+ void findListFilterByLinkId() {
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO filters (link_id, filter) VALUES (?, ?)", linkId, "java");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO filters (link_id, filter) VALUES (?, ?)", linkId, "spring");
+
+ List filters = filterDao.findListFilterByLinkId(linkId);
+
+ assertEquals(2, filters.size());
+ assertTrue(filters.stream().anyMatch(filter -> filter.filter().equals("java")));
+ assertTrue(filters.stream().anyMatch(filter -> filter.filter().equals("spring")));
+ }
+}
diff --git a/scrapper/src/test/java/datebase/dao/LinkDaoImplTest.java b/scrapper/src/test/java/datebase/dao/LinkDaoImplTest.java
new file mode 100644
index 0000000..00b32da
--- /dev/null
+++ b/scrapper/src/test/java/datebase/dao/LinkDaoImplTest.java
@@ -0,0 +1,180 @@
+package datebase.dao;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import backend.academy.scrapper.dao.link.LinkDaoImpl;
+import backend.academy.scrapper.entity.Link;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import datebase.TestDatabaseContainerDao;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+@SpringBootTest(classes = {DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, LinkDaoImpl.class})
+public class LinkDaoImplTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private LinkDaoImpl linkDao;
+
+ private Long tgChatId;
+ private Long linkId;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerDao.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Получение ссылки по ID - успешный сценарий")
+ void findLinkByLinkId_Success() {
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tags (link_id, tag) VALUES (?, ?)", linkId, "java");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO filters (link_id, filter) VALUES (?, ?)", linkId, "spring");
+ Optional result = linkDao.findLinkByLinkId(linkId);
+
+ assertTrue(result.isPresent());
+ Link link = result.get();
+ assertEquals(linkId, link.id());
+ assertEquals("https://example.com", link.url());
+ assertEquals(1, link.tags().size());
+ assertEquals(1, link.filters().size());
+ }
+
+ @Test
+ @DisplayName("Получение ссылки по ID - ссылка не найдена")
+ void findLinkByLinkId_NotFound() {
+ Optional result = linkDao.findLinkByLinkId(999L);
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки без тегов и фильтров")
+ void addLink_WithoutTagsAndFilters() {
+ Optional link = linkDao.findLinkByLinkId(linkId);
+ assertTrue(link.isPresent());
+ assertEquals("https://example.com", link.get().url());
+ assertTrue(link.get().tags().isEmpty());
+ assertTrue(link.get().filters().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Удаление существующей ссылки")
+ void remove_ExistingLink() {
+ assertDoesNotThrow(() -> linkDao.remove(linkId));
+ assertEquals(
+ 0,
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT COUNT(*) FROM links WHERE id = ?", Integer.class, linkId));
+ }
+
+ @Test
+ @DisplayName("Удаление несуществующей ссылки")
+ void remove_NonExistingLink() {
+ assertDoesNotThrow(() -> linkDao.remove(999L));
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок по IDs")
+ void getListLinksByListLinkId_Success() {
+ // Добавляем вторую ссылку
+ Long secondLinkId = 2L;
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update(
+ "INSERT INTO links (id, url, updated_at) VALUES (?, ?, ?)",
+ secondLinkId,
+ "https://example2.com",
+ OffsetDateTime.now(ZoneOffset.UTC));
+
+ List result = linkDao.getListLinksByListLinkId(List.of(linkId, secondLinkId));
+
+ assertEquals(2, result.size());
+ assertTrue(result.stream().anyMatch(l -> l.id().equals(linkId)));
+ assertTrue(result.stream().anyMatch(l -> l.id().equals(secondLinkId)));
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок по IDs - одна ссылка не найдена")
+ void getListLinksByListLinkId_OneNotFound() {
+ assertThrows(LinkNotFoundException.class, () -> linkDao.getListLinksByListLinkId(List.of(linkId, 999L)));
+ }
+
+ @Test
+ @DisplayName("Обновление существующей ссылки")
+ void update_ExistingLink() {
+ Link link = new Link()
+ .id(linkId)
+ .url("https://updated.com")
+ .description("Updated description")
+ .updatedAt(OffsetDateTime.now(ZoneOffset.UTC));
+
+ assertDoesNotThrow(() -> linkDao.update(link));
+
+ Optional updatedLink = linkDao.findLinkByLinkId(linkId);
+ assertTrue(updatedLink.isPresent());
+ assertEquals("Updated description", updatedLink.get().description());
+ }
+
+ @Test
+ @DisplayName("Поиск ссылок по chatId с фильтрацией")
+ void findAllLinksByChatIdWithFilter() {
+ // Настройка тестовых данных
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO filters (link_id, filter) VALUES (?, ?)", linkId, "java");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?)", tgChatId, "spring");
+
+ List result = linkDao.findAllLinksByChatIdWithFilter(0, 10);
+
+ assertEquals(1, result.size());
+ assertEquals(linkId, result.get(0).id());
+ }
+
+ @Test
+ @DisplayName("Поиск ссылок по chatId с фильтрацией - нет совпадений по фильтрам")
+ void findAllLinksByChatIdWithFilter_NoMatches() {
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO filters (link_id, filter) VALUES (?, ?)", linkId, "java");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO access_filter (tg_chat_id, filter) VALUES (?, ?)", tgChatId, "java");
+
+ List result = linkDao.findAllLinksByChatIdWithFilter(0, 10);
+
+ assertTrue(result.isEmpty());
+ }
+}
diff --git a/scrapper/src/test/java/datebase/dao/TagDaoImplTest.java b/scrapper/src/test/java/datebase/dao/TagDaoImplTest.java
new file mode 100644
index 0000000..d8c3afe
--- /dev/null
+++ b/scrapper/src/test/java/datebase/dao/TagDaoImplTest.java
@@ -0,0 +1,90 @@
+package datebase.dao;
+
+import backend.academy.scrapper.dao.tag.TagDaoImpl;
+import backend.academy.scrapper.entity.Tag;
+import datebase.TestDatabaseContainerDao;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+@SpringBootTest(classes = {DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, TagDaoImpl.class})
+public class TagDaoImplTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private TagDaoImpl tagDao;
+
+ private Long tgChatId;
+ private Long linkId;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerDao.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Test: поиск тегов по link_id")
+ void findListTagByLinkId() {
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tags (link_id, tag) VALUES (?, ?)", linkId, "java");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tags (link_id, tag) VALUES (?, ?)", linkId, "spring");
+ List tags = tagDao.findListTagByLinkId(linkId);
+ Assertions.assertEquals(2, tags.size());
+ Assertions.assertTrue(tags.stream().anyMatch(tag -> tag.tag().equals("java")));
+ Assertions.assertTrue(tags.stream().anyMatch(tag -> tag.tag().equals("spring")));
+ }
+
+ @Test
+ @DisplayName("Test: поиск тегов по link_id, если тестов нет")
+ void findListTagByLinkIdWithoutTags() {
+ List tags = tagDao.findListTagByLinkId(linkId);
+ Assertions.assertNotNull(tags);
+ }
+
+ @Test
+ @DisplayName("Test: удаление тега")
+ void removeTag() {
+ String tag = "docker";
+ TestDatabaseContainerDao.getJdbcTemplate().update("INSERT INTO tags (link_id, tag) VALUES (?, ?)", linkId, tag);
+ tagDao.removeTag(linkId, tag);
+ List tags = tagDao.findListTagByLinkId(linkId);
+ Assertions.assertTrue(tags.isEmpty());
+ }
+
+ @DisplayName("Test: удаление несуществующего тега")
+ @Test
+ void removeNonExistentTag() {
+ tagDao.removeTag(linkId, "nonexistent");
+ List tags = tagDao.findListTagByLinkId(linkId);
+ Assertions.assertTrue(tags.isEmpty());
+ }
+}
diff --git a/scrapper/src/test/java/datebase/dao/TgChatDaoImplTest.java b/scrapper/src/test/java/datebase/dao/TgChatDaoImplTest.java
new file mode 100644
index 0000000..c6e81a3
--- /dev/null
+++ b/scrapper/src/test/java/datebase/dao/TgChatDaoImplTest.java
@@ -0,0 +1,82 @@
+package datebase.dao;
+
+import backend.academy.scrapper.dao.chat.TgChatDaoImpl;
+import datebase.TestDatabaseContainerDao;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+@SpringBootTest(classes = {DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, TgChatDaoImpl.class})
+public class TgChatDaoImplTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private TgChatDaoImpl tgChatDao;
+
+ private Long tgChatId;
+ private Long linkId;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerDao.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerDao.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Test: сохранение чата")
+ void save() {
+ Long chatId = 2L;
+ tgChatDao.save(chatId);
+ Boolean exists = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT EXISTS (SELECT 1 FROM tg_chats WHERE id = ?)", Boolean.class, chatId);
+ Assertions.assertTrue(exists != null && exists);
+ }
+
+ @Test
+ @DisplayName("Test: удаление чата")
+ void remove() {
+ Long chatId = 2L;
+ tgChatDao.save(chatId);
+ tgChatDao.remove(chatId);
+ Boolean exists = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT EXISTS (SELECT 1 FROM tg_chats WHERE id = ?)", Boolean.class, chatId);
+ Assertions.assertFalse(exists != null && exists);
+ }
+
+ @Test
+ @DisplayName("Test: удаление несуществующего чата")
+ void remove_NonExistent() {
+ Long chatId = 2L;
+ tgChatDao.remove(chatId);
+
+ Boolean exists = TestDatabaseContainerDao.getJdbcTemplate()
+ .queryForObject("SELECT EXISTS (SELECT 1 FROM tg_chats WHERE id = ?)", Boolean.class, chatId);
+ Assertions.assertFalse(exists != null && exists);
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/TestDatabaseContainerService.java b/scrapper/src/test/java/datebase/service/TestDatabaseContainerService.java
new file mode 100644
index 0000000..d80a8b4
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/TestDatabaseContainerService.java
@@ -0,0 +1,119 @@
+package datebase.service;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import java.io.File;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import javax.sql.DataSource;
+import liquibase.Contexts;
+import liquibase.LabelExpression;
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.DirectoryResourceAccessor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+public class TestDatabaseContainerService {
+ public static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>(
+ DockerImageName.parse("postgres:15"))
+ .withDatabaseName("scrapper_db")
+ .withUsername("postgres")
+ .withPassword("postgres")
+ .withReuse(true);
+
+ static {
+ POSTGRES.start();
+ // Увеличиваем лимит соединений для тестовой БД
+ try (Connection conn = DriverManager.getConnection(
+ POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
+ Statement stmt = conn.createStatement()) {
+ stmt.execute("ALTER SYSTEM SET max_connections = 200");
+ stmt.execute("SELECT pg_reload_conf()");
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to increase max_connections", e);
+ }
+ runMigrations();
+ }
+
+ private static void runMigrations() {
+ try (var connection =
+ DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) {
+
+ Path changeLogPath = new File(".")
+ .toPath()
+ .toAbsolutePath()
+ .getParent()
+ .getParent()
+ .resolve("migrations");
+
+ var db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+
+ new Liquibase("master.xml", new DirectoryResourceAccessor(changeLogPath), db)
+ .update(new Contexts(), new LabelExpression());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to run migrations", e);
+ }
+ }
+
+ public static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+
+ private static volatile DataSource dataSource;
+ private static volatile JdbcTemplate jdbcTemplate; // Добавляем volatile
+
+ public static synchronized void cleanDatabase() {
+ if (jdbcTemplate == null) {
+ initJdbcTemplate();
+ }
+ // Очищаем таблицы с учетом зависимостей
+ try {
+ jdbcTemplate.execute("TRUNCATE TABLE tg_chat_links, access_filter, filters, tags, links, tg_chats CASCADE");
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to clean database", e);
+ }
+ }
+
+ private static synchronized void initJdbcTemplate() {
+ if (jdbcTemplate == null) {
+ HikariConfig config = new HikariConfig();
+ config.setJdbcUrl(POSTGRES.getJdbcUrl());
+ config.setUsername(POSTGRES.getUsername());
+ config.setPassword(POSTGRES.getPassword());
+ // config.setMaximumPoolSize(5);
+ config.setConnectionTimeout(30000);
+
+ dataSource = new HikariDataSource(config);
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ }
+ }
+
+ public static synchronized void closeConnections() {
+ try {
+ if (jdbcTemplate != null) {
+ DataSource dataSource = jdbcTemplate.getDataSource();
+ if (dataSource instanceof HikariDataSource) {
+ ((HikariDataSource) dataSource).close();
+ }
+ jdbcTemplate = null;
+ }
+ } catch (Exception e) {
+ System.err.println("Error closing database connections: " + e.getMessage());
+ }
+ }
+
+ public static synchronized JdbcTemplate getJdbcTemplate() {
+ return jdbcTemplate;
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/jdbc/JdbcAccessFilterServiceTest.java b/scrapper/src/test/java/datebase/service/jdbc/JdbcAccessFilterServiceTest.java
new file mode 100644
index 0000000..ffb2b81
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/jdbc/JdbcAccessFilterServiceTest.java
@@ -0,0 +1,85 @@
+package datebase.service.jdbc;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.dao.accessfilter.AccessFilterDaoImpl;
+import backend.academy.scrapper.dto.request.filter.FilterRequest;
+import backend.academy.scrapper.dto.response.filter.FilterListResponse;
+import backend.academy.scrapper.dto.response.filter.FilterResponse;
+import backend.academy.scrapper.service.jdbc.JdbcAccessFilterService;
+import datebase.service.TestDatabaseContainerService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest(
+ classes = {
+ DataSourceAutoConfiguration.class,
+ JdbcTemplateAutoConfiguration.class,
+ JdbcAccessFilterService.class,
+ AccessFilterDaoImpl.class // Реальная реализация DAO
+ })
+@TestPropertySource(properties = {"app.database-access-type=jdbc", "spring.main.allow-bean-definition-overriding=true"})
+class JdbcAccessFilterServiceTest {
+
+ @Autowired
+ private JdbcAccessFilterService jdbcAccessFilterService;
+
+ private final Long tgChatId = 1L;
+ private final String filterName = "exampleFilter";
+ private final FilterRequest filterRequest = new FilterRequest(filterName);
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerService.configureProperties(registry);
+ }
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerService.cleanDatabase();
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerService.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Создание и получение фильтра")
+ void createAndGetFilter_IntegrationTest() {
+ // Создание фильтра
+ FilterResponse createdFilter = jdbcAccessFilterService.createFilter(tgChatId, filterRequest);
+ assertNotNull(createdFilter);
+ assertEquals(filterName, createdFilter.filter());
+
+ // Получение всех фильтров
+ FilterListResponse filters = jdbcAccessFilterService.getAllFilter(tgChatId);
+ assertEquals(1, filters.filterList().size());
+ assertEquals(filterName, filters.filterList().get(0).filter());
+
+ // Удаление фильтра
+ FilterResponse deletedFilter = jdbcAccessFilterService.deleteFilter(tgChatId, filterRequest);
+ assertNotNull(deletedFilter);
+ assertEquals(filterName, deletedFilter.filter());
+
+ // Проверка, что фильтр удален
+ assertEquals(
+ 0,
+ TestDatabaseContainerService.getJdbcTemplate()
+ .queryForObject(
+ "SELECT COUNT(*) FROM access_filter WHERE tg_chat_id = ? AND filter = ?",
+ Integer.class,
+ tgChatId,
+ filterName));
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/jdbc/JdbcLinkServiceTest.java b/scrapper/src/test/java/datebase/service/jdbc/JdbcLinkServiceTest.java
new file mode 100644
index 0000000..da25a07
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/jdbc/JdbcLinkServiceTest.java
@@ -0,0 +1,170 @@
+package datebase.service.jdbc;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.dao.TgChatLinkDaoImpl;
+import backend.academy.scrapper.dao.chat.TgChatDaoImpl;
+import backend.academy.scrapper.dao.link.LinkDaoImpl;
+import backend.academy.scrapper.dto.request.AddLinkRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.entity.Link;
+import backend.academy.scrapper.exception.chat.ChatNotExistException;
+import backend.academy.scrapper.exception.link.LinkAlreadyExistException;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import backend.academy.scrapper.mapper.LinkMapper;
+import backend.academy.scrapper.service.jdbc.JdbcLinkService;
+import datebase.service.TestDatabaseContainerService;
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.Collections;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest(
+ classes = {
+ DataSourceAutoConfiguration.class,
+ JdbcTemplateAutoConfiguration.class,
+ JdbcLinkService.class,
+ TgChatDaoImpl.class,
+ LinkDaoImpl.class,
+ TgChatLinkDaoImpl.class,
+ LinkMapper.class
+ })
+@TestPropertySource(properties = {"app.database-access-type=jdbc", "spring.main.allow-bean-definition-overriding=true"})
+class JdbcLinkServiceTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerService.configureProperties(registry);
+ }
+
+ @Autowired
+ private JdbcLinkService jdbcLinkService;
+
+ private final Long tgChatId = 1L;
+ private final URI uri = URI.create("https://example.com");
+ private final AddLinkRequest addLinkRequest =
+ new AddLinkRequest(uri, Collections.emptyList(), Collections.emptyList());
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerService.cleanDatabase();
+
+ // Добавление тестового чата
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, ?)", tgChatId, OffsetDateTime.now());
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerService.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок для чата - должен вернуть пустой список для нового чата")
+ void findAllLinksByChatId_ShouldReturnEmptyListForNewChat() {
+ ListLinksResponse response = jdbcLinkService.findAllLinksByChatId(tgChatId);
+
+ assertNotNull(response);
+ assertEquals(0, response.size());
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки - должен успешно добавить ссылку и вернуть ответ")
+ void addLink_ShouldAddLinkAndReturnLinkResponse() {
+ LinkResponse response = jdbcLinkService.addLink(tgChatId, addLinkRequest);
+
+ assertNotNull(response);
+ assertEquals(uri, response.url());
+
+ // Проверка что ссылка действительно добавлена в БД
+ Integer count = TestDatabaseContainerService.getJdbcTemplate()
+ .queryForObject("SELECT COUNT(*) FROM links WHERE url = ?", Integer.class, uri.toString());
+ assertEquals(1, count);
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки - должен выбросить исключение при повторном добавлении")
+ void addLink_ShouldThrowLinkAlreadyExistException_WhenLinkAlreadyExists() {
+ jdbcLinkService.addLink(tgChatId, addLinkRequest);
+
+ assertThrows(LinkAlreadyExistException.class, () -> jdbcLinkService.addLink(tgChatId, addLinkRequest));
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки - должен успешно удалить ссылку")
+ void deleteLink_ShouldDeleteLinkAndReturnLinkResponse() {
+ LinkResponse addedLink = jdbcLinkService.addLink(tgChatId, addLinkRequest);
+
+ LinkResponse response = jdbcLinkService.deleteLink(tgChatId, uri);
+
+ assertNotNull(response);
+ assertEquals(addedLink.id(), response.id());
+
+ // Проверка что ссылка удалена из БД
+ Integer count = TestDatabaseContainerService.getJdbcTemplate()
+ .queryForObject("SELECT COUNT(*) FROM links WHERE id = ?", Integer.class, addedLink.id());
+ assertEquals(0, count);
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки - должен выбросить исключение при несуществующем чате")
+ void deleteLink_ShouldThrowChatNotExistException_WhenChatDoesNotExist() {
+ assertThrows(ChatNotExistException.class, () -> jdbcLinkService.deleteLink(999L, uri));
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки - должен выбросить исключение при несуществующей ссылке")
+ void deleteLink_ShouldThrowLinkNotFoundException_WhenLinkDoesNotExist() {
+ assertThrows(LinkNotFoundException.class, () -> jdbcLinkService.deleteLink(tgChatId, uri));
+ }
+
+ @Test
+ @DisplayName("Поиск ссылки по ID - должен вернуть ссылку при её наличии")
+ void findById_ShouldReturnLink_WhenLinkExists() {
+ LinkResponse addedLink = jdbcLinkService.addLink(tgChatId, addLinkRequest);
+
+ Optional result = jdbcLinkService.findById(addedLink.id());
+
+ assertTrue(result.isPresent());
+ assertEquals(addedLink.id(), result.get().id());
+ }
+
+ @Test
+ @DisplayName("Поиск ссылки по ID - должен вернуть пустой Optional при отсутствии ссылки")
+ void findById_ShouldReturnEmptyOptional_WhenLinkDoesNotExist() {
+ Optional result = jdbcLinkService.findById(999L);
+
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ @DisplayName("Обновление ссылки - должен успешно обновить данные ссылки")
+ void update_ShouldUpdateLink() {
+ LinkResponse addedLink = jdbcLinkService.addLink(tgChatId, addLinkRequest);
+
+ Link updatedLink = new Link()
+ .id(addedLink.id())
+ .url(uri.toString())
+ .description("updated description")
+ .updatedAt(OffsetDateTime.now());
+
+ jdbcLinkService.update(updatedLink);
+
+ // Проверка обновления в БД
+ String description = TestDatabaseContainerService.getJdbcTemplate()
+ .queryForObject("SELECT description FROM links WHERE id = ?", String.class, addedLink.id());
+ assertEquals("updated description", description);
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/jdbc/JdbcTagServiceTest.java b/scrapper/src/test/java/datebase/service/jdbc/JdbcTagServiceTest.java
new file mode 100644
index 0000000..9cb5ed1
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/jdbc/JdbcTagServiceTest.java
@@ -0,0 +1,145 @@
+package datebase.service.jdbc;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.dao.TgChatLinkDaoImpl;
+import backend.academy.scrapper.dao.filter.FilterDaoImpl;
+import backend.academy.scrapper.dao.link.LinkDaoImpl;
+import backend.academy.scrapper.dao.tag.TagDaoImpl;
+import backend.academy.scrapper.dto.request.tag.TagRemoveRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.dto.response.TagListResponse;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import backend.academy.scrapper.exception.tag.TagNotExistException;
+import backend.academy.scrapper.mapper.LinkMapper;
+import backend.academy.scrapper.service.jdbc.JdbcTagService;
+import datebase.service.TestDatabaseContainerService;
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest(
+ classes = {
+ DataSourceAutoConfiguration.class,
+ JdbcTemplateAutoConfiguration.class,
+ JdbcTagService.class,
+ FilterDaoImpl.class,
+ TagDaoImpl.class,
+ LinkDaoImpl.class,
+ TgChatLinkDaoImpl.class,
+ LinkMapper.class
+ })
+@TestPropertySource(properties = {"app.database-access-type=jdbc", "spring.main.allow-bean-definition-overriding=true"})
+class JdbcTagServiceTest {
+
+ @Autowired
+ private JdbcTagService jdbcTagService;
+
+ private Long tgChatId;
+ private Long linkId;
+ private final URI uri = URI.create("https://example.com");
+ private final String tagName = "exampleTag";
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerService.configureProperties(registry);
+ }
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerService.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ // Настройка тестовых данных
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update(
+ "INSERT INTO tg_chats (id, created_at) VALUES (?, ?)",
+ tgChatId,
+ OffsetDateTime.now(ZoneId.systemDefault()));
+
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update(
+ "INSERT INTO links (id, url, updated_at, description) VALUES (?, ?, ?, ?)",
+ linkId,
+ uri.toString(),
+ OffsetDateTime.now(ZoneId.systemDefault()),
+ "Test description");
+
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerService.closeConnections();
+ }
+
+ private void insertTestTag() {
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tags (link_id, tag) VALUES (?, ?)", linkId, tagName);
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок по тегу - должен вернуть непустой ответ с корректными данными")
+ void getListLinkByTag_ShouldReturnListLinksResponse() {
+ insertTestTag();
+ ListLinksResponse response = jdbcTagService.getListLinkByTag(tgChatId, tagName);
+ assertNotNull(response);
+ assertEquals(1, response.size());
+ assertEquals(uri.toString(), response.links().get(0).url().toString());
+ }
+
+ @Test
+ @DisplayName("Получение всех тегов для чата - должен вернуть список содержащий тест-тег")
+ void getAllListLinks_ShouldReturnTagListResponse() {
+ insertTestTag();
+ TagListResponse response = jdbcTagService.getAllListLinks(tgChatId);
+ assertNotNull(response);
+ assertTrue(response.tags().contains(tagName));
+ }
+
+ @Test
+ @DisplayName("Удаление тега из ссылки - должен успешно удалить тег и вернуть ответ")
+ void removeTagFromLink_ShouldRemoveTagAndReturnLinkResponse() {
+ insertTestTag();
+ TagRemoveRequest request = new TagRemoveRequest(tagName, uri);
+ LinkResponse response = jdbcTagService.removeTagFromLink(tgChatId, request);
+ assertNotNull(response);
+ assertEquals(
+ 0,
+ TestDatabaseContainerService.getJdbcTemplate()
+ .queryForObject(
+ "SELECT COUNT(*) FROM tags WHERE link_id = ? AND tag = ?",
+ Integer.class,
+ linkId,
+ tagName));
+ }
+
+ @Test
+ @DisplayName("Удаление тега из несуществующей ссылки - должен выбросить LinkNotFoundException")
+ void removeTagFromLink_ShouldThrowLinkNotFoundException_WhenLinkDoesNotExist() {
+ TagRemoveRequest request = new TagRemoveRequest(tagName, URI.create("https://nonexistent.com"));
+ assertThrows(LinkNotFoundException.class, () -> jdbcTagService.removeTagFromLink(tgChatId, request));
+ }
+
+ @Test
+ @DisplayName("Удаление несуществующего тега - должен выбросить TagNotExistException")
+ void removeTagFromLink_ShouldThrowTagNotExistException_WhenTagDoesNotExist() {
+ TagRemoveRequest request = new TagRemoveRequest("nonexistent-tag", uri);
+ assertThrows(TagNotExistException.class, () -> jdbcTagService.removeTagFromLink(tgChatId, request));
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/jdbc/JdbcTgChatServiceTest.java b/scrapper/src/test/java/datebase/service/jdbc/JdbcTgChatServiceTest.java
new file mode 100644
index 0000000..81a2218
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/jdbc/JdbcTgChatServiceTest.java
@@ -0,0 +1,101 @@
+package datebase.service.jdbc;
+
+import backend.academy.scrapper.dao.chat.TgChatDaoImpl;
+import backend.academy.scrapper.exception.chat.ChatAlreadyExistsException;
+import backend.academy.scrapper.exception.chat.ChatIllegalArgumentException;
+import backend.academy.scrapper.exception.chat.ChatNotExistException;
+import backend.academy.scrapper.service.ChatService;
+import backend.academy.scrapper.service.jdbc.JdbcChatService;
+import datebase.service.TestDatabaseContainerService;
+import org.junit.Assert;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest(
+ classes = {
+ DataSourceAutoConfiguration.class,
+ JdbcTemplateAutoConfiguration.class,
+ TgChatDaoImpl.class,
+ JdbcChatService.class
+ })
+@TestPropertySource(properties = {"app.database-access-type=jdbc", "spring.main.allow-bean-definition-overriding=true"})
+@ActiveProfiles("jdbc")
+public class JdbcTgChatServiceTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerService.configureProperties(registry);
+ }
+
+ @Autowired
+ private ChatService chatService;
+
+ private Long tgChatId;
+ private Long linkId;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerService.cleanDatabase();
+
+ tgChatId = 1L;
+ linkId = 1L;
+
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tg_chats (id, created_at) VALUES (?, NOW())", tgChatId);
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO links (id, url, updated_at) VALUES (?, ?, NOW())", linkId, "https://example.com");
+ TestDatabaseContainerService.getJdbcTemplate()
+ .update("INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)", tgChatId, linkId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TestDatabaseContainerService.closeConnections();
+ }
+
+ @Test
+ @DisplayName("Создание чата")
+ public void registerChatTest() {
+ chatService.registerChat(100L);
+ Assert.assertThrows(ChatAlreadyExistsException.class, () -> {
+ chatService.registerChat(100L);
+ });
+
+ Assert.assertThrows(ChatIllegalArgumentException.class, () -> {
+ chatService.registerChat(null);
+ });
+
+ Assert.assertThrows(ChatIllegalArgumentException.class, () -> {
+ chatService.registerChat(0L);
+ });
+
+ Assert.assertThrows(ChatIllegalArgumentException.class, () -> {
+ chatService.registerChat(-1L);
+ });
+ }
+
+ @Test
+ @DisplayName("Удаления чата")
+ public void deleteChatTest() {
+ Assert.assertThrows(ChatNotExistException.class, () -> {
+ chatService.deleteChat(100L);
+ });
+
+ chatService.registerChat(1000L);
+ chatService.deleteChat(1000L);
+
+ Assert.assertThrows(ChatNotExistException.class, () -> {
+ chatService.deleteChat(100L);
+ });
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/orm/OrmAccessFilterServiceTest.java b/scrapper/src/test/java/datebase/service/orm/OrmAccessFilterServiceTest.java
new file mode 100644
index 0000000..368329b
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/orm/OrmAccessFilterServiceTest.java
@@ -0,0 +1,131 @@
+package datebase.service.orm;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import backend.academy.scrapper.configuration.db.JpaConfig;
+import backend.academy.scrapper.dto.request.filter.FilterRequest;
+import backend.academy.scrapper.dto.response.filter.FilterListResponse;
+import backend.academy.scrapper.dto.response.filter.FilterResponse;
+import backend.academy.scrapper.exception.chat.ChatNotExistException;
+import backend.academy.scrapper.exception.filter.AccessFilterAlreadyExistException;
+import backend.academy.scrapper.exception.filter.AccessFilterNotExistException;
+import backend.academy.scrapper.mapper.FilterMapper;
+import backend.academy.scrapper.service.orm.OrmAccessFilterService;
+import backend.academy.scrapper.service.orm.OrmChatService;
+import datebase.TestDatabaseContainerDao;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest(
+ classes = {
+ OrmAccessFilterService.class,
+ OrmChatService.class,
+ JpaConfig.class,
+ DataSourceAutoConfiguration.class,
+ HibernateJpaAutoConfiguration.class,
+ FilterMapper.class
+ })
+@TestPropertySource(
+ properties = {
+ "app.database-access-type=orm",
+ "spring.jpa.hibernate.ddl-auto=validate",
+ "spring.jpa.show-sql=true",
+ "spring.test.database.replace=none",
+ "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect"
+ })
+@ActiveProfiles("orm")
+public class OrmAccessFilterServiceTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ private final Long tgChatId = 1L;
+ private final String testFilter = "exampleFilter";
+
+ @Autowired
+ private OrmChatService ormChatService;
+
+ @Autowired
+ private OrmAccessFilterService ormAccessFilterService;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+ ormChatService.registerChat(tgChatId);
+ }
+
+ @Test
+ @DisplayName("Создание фильтра → успешно создает новый фильтр")
+ @Transactional
+ void createFilter_ShouldCreateNewFilter() {
+ FilterRequest request = new FilterRequest(testFilter);
+ FilterResponse response = ormAccessFilterService.createFilter(tgChatId, request);
+
+ assertAll(() -> assertNotNull(response.id()), () -> assertEquals(testFilter, response.filter()));
+ }
+
+ @Test
+ @DisplayName("Создание фильтра → выбрасывает исключение при дубликате фильтра")
+ @Transactional
+ void createFilter_ShouldThrowException_WhenFilterExists() {
+ FilterRequest request = new FilterRequest(testFilter);
+ ormAccessFilterService.createFilter(tgChatId, request);
+
+ assertThrows(
+ AccessFilterAlreadyExistException.class, () -> ormAccessFilterService.createFilter(tgChatId, request));
+ }
+
+ @Test
+ @DisplayName("Создание фильтра → выбрасывает исключение при отсутствии чата")
+ @Transactional
+ void createFilter_ShouldThrowException_WhenChatNotExists() {
+ Long nonExistentChatId = 999L;
+ FilterRequest request = new FilterRequest(testFilter);
+
+ assertThrows(
+ ChatNotExistException.class, () -> ormAccessFilterService.createFilter(nonExistentChatId, request));
+ }
+
+ @Test
+ @DisplayName("Получение всех фильтров → возвращает пустой список при отсутствии фильтров")
+ @Transactional
+ void getAllFilter_ShouldReturnEmptyList_WhenNoFilters() {
+ FilterListResponse response = ormAccessFilterService.getAllFilter(tgChatId);
+ assertTrue(response.filterList().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Удаление фильтра → выбрасывает исключение при отсутствии фильтра")
+ @Transactional
+ void deleteFilter_ShouldThrowException_WhenFilterNotExists() {
+ assertThrows(
+ AccessFilterNotExistException.class,
+ () -> ormAccessFilterService.deleteFilter(tgChatId, new FilterRequest(testFilter)));
+ }
+
+ @Test
+ @DisplayName("Удаление фильтра → выбрасывает исключение при отсутствии чата")
+ @Transactional
+ void deleteFilter_ShouldThrowException_WhenChatNotExists() {
+ Long nonExistentChatId = 999L;
+ assertThrows(
+ ChatNotExistException.class,
+ () -> ormAccessFilterService.deleteFilter(nonExistentChatId, new FilterRequest(testFilter)));
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/orm/OrmChatServiceTest.java b/scrapper/src/test/java/datebase/service/orm/OrmChatServiceTest.java
new file mode 100644
index 0000000..4573cc1
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/orm/OrmChatServiceTest.java
@@ -0,0 +1,95 @@
+package datebase.service.orm;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.configuration.db.JpaConfig;
+import backend.academy.scrapper.entity.TgChat;
+import backend.academy.scrapper.exception.chat.ChatAlreadyExistsException;
+import backend.academy.scrapper.service.orm.OrmChatService;
+import datebase.TestDatabaseContainerDao;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest(
+ classes = {
+ OrmChatService.class,
+ JpaConfig.class,
+ DataSourceAutoConfiguration.class,
+ HibernateJpaAutoConfiguration.class
+ })
+@TestPropertySource(
+ properties = {
+ "app.database-access-type=orm",
+ "spring.jpa.hibernate.ddl-auto=validate",
+ "spring.jpa.show-sql=true",
+ "spring.test.database.replace=none",
+ "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect"
+ })
+@ActiveProfiles("orm")
+class OrmChatServiceTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private OrmChatService ormChatService;
+
+ private final Long tgChatId = 1L;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+ }
+
+ @Test
+ @DisplayName("Регистрация чата - должен успешно сохранить новый чат")
+ @Transactional
+ void registerChat_ShouldRegisterChat() {
+ ormChatService.registerChat(tgChatId);
+ Optional foundChat = ormChatService.findChatById(tgChatId);
+ assertTrue(foundChat.isPresent());
+ assertEquals(tgChatId, foundChat.get().id());
+ }
+
+ @Test
+ @DisplayName("Регистрация чата - должен выбросить исключение при существующем чате")
+ @Transactional
+ void registerChat_ShouldThrowChatAlreadyExistsException_WhenChatAlreadyExists() {
+ ormChatService.registerChat(tgChatId);
+ assertThrows(ChatAlreadyExistsException.class, () -> ormChatService.registerChat(tgChatId));
+ }
+
+ @Test
+ @DisplayName("Поиск чата по ID - должен вернуть чат при его наличии")
+ @Transactional
+ void findChatById_ShouldReturnChat_WhenChatExists() {
+ ormChatService.registerChat(tgChatId);
+ Optional foundChat = ormChatService.findChatById(tgChatId);
+ assertTrue(foundChat.isPresent());
+ assertEquals(tgChatId, foundChat.get().id());
+ }
+
+ @Test
+ @DisplayName("Поиск чата по ID - должен вернуть пустой Optional при отсутствии чата")
+ @Transactional
+ void findChatById_ShouldReturnEmptyOptional_WhenChatDoesNotExist() {
+ // Act
+ Optional foundChat = ormChatService.findChatById(tgChatId);
+
+ // Assert
+ assertFalse(foundChat.isPresent());
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/orm/OrmLinkServiceTest.java b/scrapper/src/test/java/datebase/service/orm/OrmLinkServiceTest.java
new file mode 100644
index 0000000..f7ecc29
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/orm/OrmLinkServiceTest.java
@@ -0,0 +1,174 @@
+package datebase.service.orm;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.configuration.db.JpaConfig;
+import backend.academy.scrapper.dto.request.AddLinkRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.entity.Link;
+import backend.academy.scrapper.exception.chat.ChatNotExistException;
+import backend.academy.scrapper.exception.link.LinkAlreadyExistException;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import backend.academy.scrapper.mapper.LinkMapper;
+import backend.academy.scrapper.service.orm.OrmChatService;
+import backend.academy.scrapper.service.orm.OrmLinkService;
+import datebase.TestDatabaseContainerDao;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest(
+ classes = {
+ OrmLinkService.class,
+ LinkMapper.class,
+ OrmChatService.class,
+ JpaConfig.class,
+ DataSourceAutoConfiguration.class,
+ HibernateJpaAutoConfiguration.class
+ })
+@TestPropertySource(
+ properties = {
+ "spring.jpa.hibernate.ddl-auto=validate",
+ "spring.jpa.show-sql=true",
+ "spring.test.database.replace=none",
+ "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect"
+ })
+@ActiveProfiles("orm")
+class OrmLinkServiceTest {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private OrmLinkService ormLinkService;
+
+ @Autowired
+ private OrmChatService ormChatService;
+
+ private final Long tgChatId = 1L;
+ private final URI uri = URI.create("https://example.com");
+ private final AddLinkRequest addLinkRequest = new AddLinkRequest(uri, List.of("tag1", "tag2"), List.of("filter1"));
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+ ormChatService.registerChat(tgChatId);
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки → успешно создает новую ссылку")
+ @Transactional
+ void addLink_ShouldCreateNewLink() {
+ LinkResponse response = ormLinkService.addLink(tgChatId, addLinkRequest);
+ assertAll(
+ () -> assertNotNull(response.id()),
+ () -> assertEquals(uri, response.url()),
+ () -> assertEquals(2, response.tags().size()),
+ () -> assertEquals(1, response.filters().size()));
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки → выбрасывает исключение при дубликате ссылки")
+ @Transactional
+ void addLink_ShouldThrowException_WhenLinkExists() {
+ ormLinkService.addLink(tgChatId, addLinkRequest);
+
+ assertThrows(LinkAlreadyExistException.class, () -> ormLinkService.addLink(tgChatId, addLinkRequest));
+ }
+
+ @Test
+ @DisplayName("Добавление ссылки → выбрасывает исключение при отсутствии чата")
+ @Transactional
+ void addLink_ShouldThrowException_WhenChatNotExists() {
+ Long nonExistentChatId = 999L;
+
+ assertThrows(ChatNotExistException.class, () -> ormLinkService.addLink(nonExistentChatId, addLinkRequest));
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки → успешно удаляет существующую ссылку")
+ @Transactional
+ void deleteLink_ShouldRemoveExistingLink() {
+ LinkResponse addedLink = ormLinkService.addLink(tgChatId, addLinkRequest);
+ LinkResponse deletedLink = ormLinkService.deleteLink(tgChatId, uri);
+
+ assertEquals(addedLink.id(), deletedLink.id());
+ }
+
+ @Test
+ @DisplayName("Удаление ссылки → выбрасывает исключение при отсутствии ссылки")
+ @Transactional
+ void deleteLink_ShouldThrowException_WhenLinkNotExists() {
+ assertThrows(LinkNotFoundException.class, () -> ormLinkService.deleteLink(tgChatId, uri));
+ }
+
+ @Test
+ @DisplayName("Поиск ссылки по ID → возвращает ссылку при ее наличии")
+ @Transactional
+ void findById_ShouldReturnLink_WhenExists() {
+ LinkResponse addedLink = ormLinkService.addLink(tgChatId, addLinkRequest);
+ Optional foundLink = ormLinkService.findById(addedLink.id());
+
+ assertTrue(foundLink.isPresent());
+ assertEquals(addedLink.id(), foundLink.get().id());
+ }
+
+ @Test
+ @DisplayName("Поиск ссылки по ID → возвращает пустой Optional при отсутствии ссылки")
+ @Transactional
+ void findById_ShouldReturnEmpty_WhenNotExists() {
+ Optional foundLink = ormLinkService.findById(999L);
+ assertFalse(foundLink.isPresent());
+ }
+
+ @Test
+ @DisplayName("Обновление ссылки → успешно обновляет данные")
+ @Transactional
+ void update_ShouldUpdateLink() {
+ LinkResponse addedLink = ormLinkService.addLink(tgChatId, addLinkRequest);
+ Link linkToUpdate = new Link();
+ linkToUpdate.id(addedLink.id());
+ linkToUpdate.url(uri.toString());
+ linkToUpdate.description("Updated description");
+
+ ormLinkService.update(linkToUpdate);
+
+ Optional updatedLink = ormLinkService.findById(addedLink.id());
+ assertTrue(updatedLink.isPresent());
+ assertEquals("Updated description", updatedLink.get().description());
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок → возвращает ссылки для указанного чата")
+ @Transactional
+ void findAllLinksByChatId_ShouldReturnLinksForChat() {
+ ormLinkService.addLink(tgChatId, addLinkRequest);
+ ListLinksResponse response = ormLinkService.findAllLinksByChatId(tgChatId);
+
+ assertEquals(1, response.links().size());
+ assertEquals(uri, response.links().get(0).url());
+ }
+
+ @Test
+ @DisplayName("Получение списка ссылок → возвращает пустой список для чата без ссылок")
+ @Transactional
+ void findAllLinksByChatId_ShouldReturnEmptyList_WhenNoLinks() {
+ ListLinksResponse response = ormLinkService.findAllLinksByChatId(tgChatId);
+ assertTrue(response.links().isEmpty());
+ }
+}
diff --git a/scrapper/src/test/java/datebase/service/orm/OrmTagServiceTest.java b/scrapper/src/test/java/datebase/service/orm/OrmTagServiceTest.java
new file mode 100644
index 0000000..cb596b4
--- /dev/null
+++ b/scrapper/src/test/java/datebase/service/orm/OrmTagServiceTest.java
@@ -0,0 +1,170 @@
+package datebase.service.orm;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.configuration.db.JpaConfig;
+import backend.academy.scrapper.dto.request.AddLinkRequest;
+import backend.academy.scrapper.dto.request.tag.TagRemoveRequest;
+import backend.academy.scrapper.dto.response.LinkResponse;
+import backend.academy.scrapper.dto.response.ListLinksResponse;
+import backend.academy.scrapper.dto.response.TagListResponse;
+import backend.academy.scrapper.entity.TgChat;
+import backend.academy.scrapper.exception.link.LinkNotFoundException;
+import backend.academy.scrapper.exception.tag.TagNotExistException;
+import backend.academy.scrapper.mapper.LinkMapper;
+import backend.academy.scrapper.service.ChatService;
+import backend.academy.scrapper.service.orm.OrmChatService;
+import backend.academy.scrapper.service.orm.OrmLinkService;
+import backend.academy.scrapper.service.orm.OrmTagService;
+import datebase.TestDatabaseContainerDao;
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest(
+ classes = {
+ OrmTagService.class,
+ OrmLinkService.class,
+ OrmChatService.class,
+ JpaConfig.class,
+ DataSourceAutoConfiguration.class,
+ HibernateJpaAutoConfiguration.class,
+ LinkMapper.class,
+ })
+@TestPropertySource(
+ properties = {
+ "app.database-access-type=orm",
+ "spring.jpa.hibernate.ddl-auto=validate",
+ "spring.jpa.show-sql=true",
+ "spring.test.database.replace=none",
+ "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect"
+ })
+@ActiveProfiles("orm")
+class OrmTagServiceTest {
+
+ @Autowired
+ private ChatService chatService;
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ TestDatabaseContainerDao.configureProperties(registry);
+ }
+
+ @Autowired
+ private OrmTagService ormTagService;
+
+ @BeforeEach
+ void setUp() {
+ TestDatabaseContainerDao.cleanDatabase();
+ ormChatService.registerChat(tgChatId);
+
+ // Проверка, что чат создан с инициализированной коллекцией
+ TgChat chat = chatService.findChatById(tgChatId).orElseThrow();
+ assertNotNull(chat.id());
+ }
+
+ @Autowired
+ private OrmLinkService ormLinkService;
+
+ @Autowired
+ private OrmChatService ormChatService;
+
+ private final Long tgChatId = 1L;
+ private final URI uri = URI.create("https://example.com");
+ private final String tagName = "exampleTag";
+
+ @Test
+ @DisplayName("При удалении тега из несуществующей ссылки → выбрасывается LinkNotFoundException")
+ @Transactional
+ void removeTagFromNonExistentLink_ThrowsLinkNotFoundException() {
+ TagRemoveRequest request = new TagRemoveRequest(tagName, uri);
+ assertThrows(LinkNotFoundException.class, () -> ormTagService.removeTagFromLink(tgChatId, request));
+ }
+
+ @Test
+ @DisplayName("При удалении несуществующего тега → выбрасывается TagNotExistException")
+ @Transactional
+ void removeNonExistentTag_ThrowsTagNotExistException() {
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri, List.of("otherTag"), List.of()));
+ assertThrows(
+ TagNotExistException.class,
+ () -> ormTagService.removeTagFromLink(tgChatId, new TagRemoveRequest(tagName, uri)));
+ }
+
+ @Test
+ @DisplayName("При удалении существующего тега → тег успешно удаляется из ссылки")
+ @Transactional
+ void removeExistingTag_RemovesTagSuccessfully() {
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri, List.of(tagName, "persistentTag"), List.of()));
+
+ LinkResponse response = ormTagService.removeTagFromLink(tgChatId, new TagRemoveRequest(tagName, uri));
+
+ assertAll(
+ () -> assertFalse(response.tags().contains(tagName)),
+ () -> assertTrue(response.tags().contains("persistentTag")));
+ }
+
+ @Test
+ @DisplayName("При запросе ссылок по тегу → возвращаются только ссылки с этим тегом")
+ @Transactional
+ void getLinksByTag_ReturnsOnlyMatchingLinks() {
+ URI uri1 = URI.create("https://example.com/1");
+ URI uri2 = URI.create("https://example.com/2");
+ String targetTag = "targetTag";
+
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri1, List.of(targetTag, "commonTag"), List.of()));
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri2, List.of("commonTag"), List.of()));
+
+ ListLinksResponse result = ormTagService.getListLinkByTag(tgChatId, targetTag);
+
+ assertAll(
+ () -> assertEquals(1, result.links().size()),
+ () -> assertTrue(result.links().get(0).tags().contains(targetTag)));
+ }
+
+ @Test
+ @DisplayName("При запросе всех тегов → возвращаются уникальные теги без дубликатов")
+ @Transactional
+ void getAllTags_ReturnsUniqueTags() {
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri, List.of("tag1", "tag1", "tag2"), List.of()));
+
+ TagListResponse result = ormTagService.getAllListLinks(tgChatId);
+
+ assertAll(
+ () -> assertEquals(2, result.tags().size()),
+ () -> assertTrue(result.tags().containsAll(List.of("tag1", "tag2"))));
+ }
+
+ @Test
+ @DisplayName("При запросе тегов для чата без ссылок → возвращается пустой список")
+ @Transactional
+ void getTagsForChatWithoutLinks_ReturnsEmptyList() {
+ TagListResponse result = ormTagService.getAllListLinks(tgChatId);
+ assertTrue(result.tags().isEmpty());
+ }
+
+ @Test
+ @DisplayName("При удалении тега → другие теги той же ссылки остаются неизменными")
+ @Transactional
+ void removeTag_DoesNotAffectOtherTags() {
+ ormLinkService.addLink(tgChatId, new AddLinkRequest(uri, List.of("tag1", "tag2", "tag3"), List.of()));
+
+ LinkResponse response = ormTagService.removeTagFromLink(tgChatId, new TagRemoveRequest("tag2", uri));
+
+ assertAll(
+ () -> assertFalse(response.tags().contains("tag2")),
+ () -> assertTrue(response.tags().contains("tag1")),
+ () -> assertTrue(response.tags().contains("tag3")));
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/RateLimitKafkaTestContainer.java b/scrapper/src/test/java/ratelimit/RateLimitKafkaTestContainer.java
new file mode 100644
index 0000000..c03ecf4
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/RateLimitKafkaTestContainer.java
@@ -0,0 +1,25 @@
+package ratelimit;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+public class RateLimitKafkaTestContainer {
+
+ @Container
+ public static org.testcontainers.containers.KafkaContainer kafka =
+ new org.testcontainers.containers.KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
+
+ static {
+ kafka.start();
+ }
+
+ @DynamicPropertySource
+ public static void kafkaProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
+ registry.add("spring.embedded.kafka.brokers", kafka::getBootstrapServers);
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/RateLimitTestDatabaseContainer.java b/scrapper/src/test/java/ratelimit/RateLimitTestDatabaseContainer.java
new file mode 100644
index 0000000..998b0c2
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/RateLimitTestDatabaseContainer.java
@@ -0,0 +1,68 @@
+package ratelimit;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import liquibase.Contexts;
+import liquibase.LabelExpression;
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.DirectoryResourceAccessor;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+public class RateLimitTestDatabaseContainer {
+ public static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>(
+ DockerImageName.parse("postgres:15"))
+ .withDatabaseName("scrapper_db")
+ .withUsername("postgres")
+ .withPassword("postgres")
+ .withReuse(true);
+
+ static {
+ POSTGRES.start();
+ // Увеличиваем лимит соединений для тестовой БД
+ try (Connection conn = DriverManager.getConnection(
+ POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
+ Statement stmt = conn.createStatement()) {
+ stmt.execute("ALTER SYSTEM SET max_connections = 200");
+ stmt.execute("SELECT pg_reload_conf()");
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to increase max_connections", e);
+ }
+ runMigrations();
+ }
+
+ private static void runMigrations() {
+ try (var connection =
+ DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) {
+
+ Path changeLogPath = new File(".")
+ .toPath()
+ .toAbsolutePath()
+ .getParent()
+ .getParent()
+ .resolve("migrations");
+
+ var db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+
+ new Liquibase("master.xml", new DirectoryResourceAccessor(changeLogPath), db)
+ .update(new Contexts(), new LabelExpression());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to run migrations", e);
+ }
+ }
+
+ public static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/controller/ChatControllerRateLimitIntegrationTest.java b/scrapper/src/test/java/ratelimit/controller/ChatControllerRateLimitIntegrationTest.java
new file mode 100644
index 0000000..37c08e0
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/controller/ChatControllerRateLimitIntegrationTest.java
@@ -0,0 +1,89 @@
+package ratelimit.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.ScrapperApplication;
+import backend.academy.scrapper.limit.RateLimitProperties;
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import ratelimit.RateLimitKafkaTestContainer;
+import ratelimit.RateLimitTestDatabaseContainer;
+
+@SpringBootTest(classes = ScrapperApplication.class)
+@AutoConfigureMockMvc
+public class ChatControllerRateLimitIntegrationTest implements RateLimitIntegration {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ RateLimitTestDatabaseContainer.configureProperties(registry);
+ RateLimitKafkaTestContainer.kafkaProperties(registry);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private RateLimitProperties rateLimitProperties;
+
+ @Test
+ @DisplayName("ChatController register: Проверяем что с одного IP включается RateLimit")
+ public void registerChat_testRateLimiting() throws Exception {
+ mockMvc.perform(post("/tg-chat/123").with(remoteAddr("193.168.2.1"))).andExpect(status().isOk());
+ for (int i = 0; i < rateLimitProperties.capacity() - 1; i++) {
+ mockMvc.perform(post("/tg-chat/123").with(remoteAddr("193.168.2.1")))
+ .andExpect(status().isBadRequest());
+ }
+ mockMvc.perform(post("/tg-chat/123").with(remoteAddr("193.168.2.1"))).andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @SneakyThrows
+ @DisplayName("ChatController register: Проверяем что с разных IP не включается RateLimit")
+ public void registerChat_testRateLimitingIP() {
+ mockMvc.perform(post("/tg-chat/1236").with(remoteAddr("193.168.1.1"))).andExpect(status().isOk());
+ for (int i = 0; i < rateLimitProperties.capacity() - 1; i++) {
+ mockMvc.perform(post("/tg-chat/1236").with(remoteAddr("192.168.1.1")))
+ .andExpect(status().isBadRequest());
+ }
+
+ mockMvc.perform(post("/tg-chat/1236").with(request -> {
+ request.setRemoteAddr("192.168.1.5");
+ return request;
+ }))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("ChatController deleteChat: Проверяем что с одного IP включается RateLimit")
+ public void deleteChat_testRateLimiting() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/tg-chat/55").with(remoteAddr("192.168.1.10")))
+ .andExpect(status().isOk());
+ }
+ mockMvc.perform(delete("/tg-chat/55").with(remoteAddr("192.168.1.10"))).andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @SneakyThrows
+ @DisplayName("ChatController deleteChat: Проверяем что с разных IP не включается RateLimit")
+ public void deleteChat_testRateLimitingIP() {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/tg-chat/55").with(remoteAddr("192.168.1.11")))
+ .andExpect(status().isOk());
+ }
+ mockMvc.perform(delete("/tg-chat/55").with(request -> {
+ request.setRemoteAddr("192.168.1.15");
+ return request;
+ }))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/controller/FilterControllerRateLimitIntegrationTest.java b/scrapper/src/test/java/ratelimit/controller/FilterControllerRateLimitIntegrationTest.java
new file mode 100644
index 0000000..2a295b9
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/controller/FilterControllerRateLimitIntegrationTest.java
@@ -0,0 +1,148 @@
+package ratelimit.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.ScrapperApplication;
+import backend.academy.scrapper.limit.RateLimitProperties;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import ratelimit.RateLimitKafkaTestContainer;
+import ratelimit.RateLimitTestDatabaseContainer;
+
+@SpringBootTest(classes = ScrapperApplication.class)
+@AutoConfigureMockMvc
+public class FilterControllerRateLimitIntegrationTest implements RateLimitIntegration {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ RateLimitTestDatabaseContainer.configureProperties(registry);
+ RateLimitKafkaTestContainer.kafkaProperties(registry);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private RateLimitProperties rateLimitProperties;
+
+ private static final Long TG_CHAT_ID = 54321L;
+ private static final String TEST_FILTER = "test-filter";
+
+ @Test
+ @DisplayName("FilterController createFilter: Проверяем что с одного IP включается RateLimit")
+ public void createFilter_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(post("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"" + TEST_FILTER + i + "\"}")
+ .with(remoteAddr("192.168.4.1")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что следующий запрос получает TooManyRequests
+ mockMvc.perform(post("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"overflow-filter\"}")
+ .with(remoteAddr("192.168.4.1")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("FilterController createFilter: Проверяем что с разных IP не включается RateLimit")
+ public void createFilter_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(post("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"" + TEST_FILTER + i + "\"}")
+ .with(remoteAddr("192.168.4.2")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что с другого IP запросы проходят
+ mockMvc.perform(post("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"another-ip-filter\"}")
+ .with(remoteAddr("192.168.4.3")))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("FilterController getAllFilter: Проверяем что с одного IP включается RateLimit")
+ public void getAllFilter_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/filter/" + TG_CHAT_ID).with(remoteAddr("192.168.4.4")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что следующий запрос получает TooManyRequests
+ mockMvc.perform(get("/filter/" + TG_CHAT_ID).with(remoteAddr("192.168.4.4")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("FilterController getAllFilter: Проверяем что с разных IP не включается RateLimit")
+ public void getAllFilter_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/filter/" + TG_CHAT_ID).with(remoteAddr("192.168.4.5")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что с другого IP запросы проходят
+ mockMvc.perform(get("/filter/" + TG_CHAT_ID).with(remoteAddr("192.168.4.6")))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("FilterController deleteFilter: Проверяем что с одного IP включается RateLimit")
+ public void deleteFilter_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"" + TEST_FILTER + i + "\"}")
+ .with(remoteAddr("192.168.4.7")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что следующий запрос получает TooManyRequests
+ mockMvc.perform(delete("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"overflow-filter\"}")
+ .with(remoteAddr("192.168.4.7")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("FilterController deleteFilter: Проверяем что с разных IP не включается RateLimit")
+ public void deleteFilter_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"" + TEST_FILTER + i + "\"}")
+ .with(remoteAddr("192.168.4.8")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем, что с другого IP запросы проходят
+ mockMvc.perform(delete("/filter/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"filter\": \"another-ip-filter\"}")
+ .with(remoteAddr("192.168.4.9")))
+ .andExpect(status().isBadRequest());
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/controller/LinkControllerRateLimitIntegrationTest.java b/scrapper/src/test/java/ratelimit/controller/LinkControllerRateLimitIntegrationTest.java
new file mode 100644
index 0000000..ef66bd3
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/controller/LinkControllerRateLimitIntegrationTest.java
@@ -0,0 +1,189 @@
+package ratelimit.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.ScrapperApplication;
+import backend.academy.scrapper.limit.RateLimitProperties;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import ratelimit.RateLimitKafkaTestContainer;
+import ratelimit.RateLimitTestDatabaseContainer;
+
+@SpringBootTest(classes = ScrapperApplication.class)
+@AutoConfigureMockMvc
+public class LinkControllerRateLimitIntegrationTest implements RateLimitIntegration {
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ RateLimitTestDatabaseContainer.configureProperties(registry);
+ RateLimitKafkaTestContainer.kafkaProperties(registry);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private RateLimitProperties rateLimitProperties;
+
+ private static final Long TG_CHAT_ID = 12345L;
+
+ @Test
+ @DisplayName("LinkController getAllLinks: Проверяем что с одного IP включается RateLimit")
+ public void getAllLinks_testRateLimiting() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/links").header("Tg-Chat-Id", TG_CHAT_ID).with(remoteAddr("192.168.3.1")))
+ .andExpect(status().isOk());
+ }
+
+ mockMvc.perform(get("/links").header("Tg-Chat-Id", TG_CHAT_ID).with(remoteAddr("192.168.3.1")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("LinkController getAllLinks: Проверяем что с разных IP не включается RateLimit")
+ public void getAllLinks_testRateLimitingIP() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/links").header("Tg-Chat-Id", TG_CHAT_ID).with(remoteAddr("192.168.3.2")))
+ .andExpect(status().isOk());
+ }
+
+ mockMvc.perform(get("/links").header("Tg-Chat-Id", TG_CHAT_ID).with(remoteAddr("192.168.3.3")))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("LinkController addLink: Проверяем что с одного IP включается RateLimit")
+ public void addLink_testRateLimiting() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(post("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://github.com",
+ "tags": ["java", "spring"],
+ "filters": ["comments", "updates"]
+ }
+ """)
+ .with(remoteAddr("192.168.3.5")))
+ .andExpect(status().isBadRequest());
+ }
+ mockMvc.perform(post("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com",
+ "tags": ["java", "spring"],
+ "filters": ["comments", "updates"]
+ }
+ """)
+ .with(remoteAddr("192.168.3.5")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("LinkController addLink: Проверяем что с разных IP не включается RateLimit")
+ public void addLink_testRateLimitingIP() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(post("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://github.com",
+ "tags": ["java", "spring"],
+ "filters": ["comments", "updates"]
+ }
+ """)
+ .with(remoteAddr("192.168.3.7")))
+ .andExpect(status().isBadRequest());
+ }
+
+ mockMvc.perform(post("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com",
+ "tags": ["java", "spring"],
+ "filters": ["comments", "updates"]
+ }
+ """)
+ .with(remoteAddr("192.168.3.200")))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("LinkController deleteLink: Проверяем что с одного IP включается RateLimit")
+ public void deleteLink_testRateLimiting() throws Exception {
+
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com"
+ }
+ """)
+ .with(remoteAddr("192.168.3.8")))
+ .andExpect(status().isBadRequest());
+ }
+
+ mockMvc.perform(delete("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://example.com"
+ }
+ """)
+ .with(remoteAddr("192.168.3.8")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("LinkController deleteLink: Проверяем что с разных IP не включается RateLimit")
+ public void deleteLink_testRateLimitingIP() throws Exception {
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://github.com"
+ }
+ """)
+ .with(remoteAddr("192.168.3.9")))
+ .andExpect(status().isBadRequest());
+ }
+ mockMvc.perform(delete("/links/" + TG_CHAT_ID)
+ .header("Tg-Chat-Id", TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "link": "https://github.com"
+ }
+ """)
+ .with(remoteAddr("192.168.3.10")))
+ .andExpect(status().isBadRequest());
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/controller/RateLimitIntegration.java b/scrapper/src/test/java/ratelimit/controller/RateLimitIntegration.java
new file mode 100644
index 0000000..183bef5
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/controller/RateLimitIntegration.java
@@ -0,0 +1,14 @@
+package ratelimit.controller;
+
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+public interface RateLimitIntegration {
+ // Вспомогательный метод для установки IP-адреса
+
+ default RequestPostProcessor remoteAddr(String remoteAddr) {
+ return request -> {
+ request.setRemoteAddr(remoteAddr);
+ return request;
+ };
+ }
+}
diff --git a/scrapper/src/test/java/ratelimit/controller/TagControllerRateLimitIntegrationTest.java b/scrapper/src/test/java/ratelimit/controller/TagControllerRateLimitIntegrationTest.java
new file mode 100644
index 0000000..a731880
--- /dev/null
+++ b/scrapper/src/test/java/ratelimit/controller/TagControllerRateLimitIntegrationTest.java
@@ -0,0 +1,168 @@
+package ratelimit.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import backend.academy.scrapper.ScrapperApplication;
+import backend.academy.scrapper.limit.RateLimitProperties;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import ratelimit.RateLimitKafkaTestContainer;
+import ratelimit.RateLimitTestDatabaseContainer;
+
+@SpringBootTest(classes = ScrapperApplication.class)
+@AutoConfigureMockMvc
+public class TagControllerRateLimitIntegrationTest implements RateLimitIntegration {
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ RateLimitTestDatabaseContainer.configureProperties(registry);
+ RateLimitKafkaTestContainer.kafkaProperties(registry);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private RateLimitProperties rateLimitProperties;
+
+ private static final Long TG_CHAT_ID = 67890L;
+ private static final String TEST_TAG = "test-tag";
+ private static final String TEST_URI = "https://example.com";
+
+ @Test
+ @DisplayName("TagController getListLinksByTag: Проверяем что с одного IP включается RateLimit")
+ public void getListLinksByTag_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "tag" : "tag1"
+ }
+ """)
+ .with(remoteAddr("192.168.5.1")))
+ .andExpect(status().isOk());
+ }
+
+ // Проверяем превышение лимита
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "tag" : "tag1"
+ }
+ """)
+ .with(remoteAddr("192.168.5.1")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("TagController getListLinksByTag: Проверяем что с разных IP не включается RateLimit")
+ public void getListLinksByTag_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "tag" : "tag1"
+ }
+ """)
+ .with(remoteAddr("192.168.5.2")))
+ .andExpect(status().isOk());
+ }
+
+ // Проверяем запрос с другого IP
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "tag" : "tag1"
+ }
+ """)
+ .with(remoteAddr("192.168.5.3")))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("TagController getAllListLinksByTag: Проверяем что с одного IP включается RateLimit")
+ public void getAllListLinksByTag_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID + "/all").with(remoteAddr("192.168.5.4")))
+ .andExpect(status().isOk());
+ }
+
+ // Проверяем превышение лимита
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID + "/all").with(remoteAddr("192.168.5.4")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("TagController getAllListLinksByTag: Проверяем что с разных IP не включается RateLimit")
+ public void getAllListLinksByTag_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID + "/all").with(remoteAddr("192.168.5.5")))
+ .andExpect(status().isOk());
+ }
+
+ // Проверяем запрос с другого IP
+ mockMvc.perform(get("/tag/" + TG_CHAT_ID + "/all").with(remoteAddr("192.168.5.6")))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("TagController removeTagFromLink: Проверяем что с одного IP включается RateLimit")
+ public void removeTagFromLink_testRateLimiting() throws Exception {
+ // Имитируем несколько запросов до достижения лимита
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"tag\": \"" + TEST_TAG + i + "\", \"uri\": \"" + TEST_URI + i + "\"}")
+ .with(remoteAddr("192.168.5.7")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем превышение лимита
+ mockMvc.perform(delete("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"tag\": \"overflow-tag\", \"uri\": \"https://overflow.com\"}")
+ .with(remoteAddr("192.168.5.7")))
+ .andExpect(status().isTooManyRequests());
+ }
+
+ @Test
+ @DisplayName("TagController removeTagFromLink: Проверяем что с разных IP не включается RateLimit")
+ public void removeTagFromLink_testRateLimitingIP() throws Exception {
+ // Заполняем лимит для первого IP
+ for (int i = 0; i < rateLimitProperties.capacity(); i++) {
+ mockMvc.perform(delete("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"tag\": \"" + TEST_TAG + i + "\", \"uri\": \"" + TEST_URI + i + "\"}")
+ .with(remoteAddr("192.168.5.8")))
+ .andExpect(status().isBadRequest());
+ }
+
+ // Проверяем запрос с другого IP
+ mockMvc.perform(delete("/tag/" + TG_CHAT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"tag\": \"another-ip-tag\", \"uri\": \"https://another.com\"}")
+ .with(remoteAddr("192.168.5.9")))
+ .andExpect(status().isBadRequest());
+ }
+}
diff --git a/scrapper/src/test/java/tracker/GitHubClientTest.java b/scrapper/src/test/java/tracker/GitHubClientTest.java
new file mode 100644
index 0000000..2f64dc5
--- /dev/null
+++ b/scrapper/src/test/java/tracker/GitHubClientTest.java
@@ -0,0 +1,106 @@
+package tracker;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import backend.academy.scrapper.configuration.ScrapperConfig;
+import backend.academy.scrapper.configuration.api.WebClientProperties;
+import backend.academy.scrapper.tracker.client.GitHubClient;
+import backend.academy.scrapper.tracker.request.GitHubRequest;
+import backend.academy.scrapper.tracker.response.github.IssueResponse;
+import backend.academy.scrapper.tracker.response.github.PullRequestResponse;
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.reactive.function.client.WebClient;
+
+class GitHubClientTest {
+
+ private WebClient webClient;
+ private WebClient.RequestHeadersUriSpec requestHeadersUriSpec;
+ private WebClient.RequestHeadersSpec requestHeadersSpec;
+ private WebClient.ResponseSpec responseSpec;
+ private GitHubClient gitHubClient;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ webClient = mock(WebClient.class);
+ requestHeadersUriSpec = mock(WebClient.RequestHeadersUriSpec.class);
+ requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class);
+ responseSpec = mock(WebClient.ResponseSpec.class);
+
+ // Настраиваем моки
+ when(webClient.get()).thenReturn(requestHeadersUriSpec);
+ when(requestHeadersUriSpec.uri((URI) any())).thenReturn(requestHeadersSpec); // Используем any() для Function
+ when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
+
+ // Создаем клиент
+ WebClientProperties webClientProperties = new WebClientProperties();
+
+ ScrapperConfig.GithubCredentials credentials =
+ new ScrapperConfig.GithubCredentials("https://api.github.com", "test-token");
+ gitHubClient = new GitHubClient(credentials, webClientProperties);
+
+ Field webClientField = GitHubClient.class.getSuperclass().getDeclaredField("webClient");
+ webClientField.setAccessible(true);
+ webClientField.set(gitHubClient, webClient);
+ }
+
+ @Test
+ @DisplayName("fetchPullRequest: возвращает Optional с пустым списком, если since = null")
+ void fetchPullRequest_ShouldReturnEmptyOptional_WhenSinceIsNull() {
+ // Вызов метода с since = null
+ GitHubRequest request = new GitHubRequest("user", "repo");
+ Optional> result = gitHubClient.fetchPullRequest(request, null);
+
+ // Проверки
+ assertTrue(result.isPresent());
+ assertTrue(result.get().isEmpty());
+
+ // Проверка, что WebClient не вызывался
+ verify(webClient, never()).get();
+ }
+
+ @Test
+ @DisplayName("fetchIssue: возвращает Optional с пустым списком, если since = null")
+ void fetchIssue_ShouldReturnEmptyOptional_WhenSinceIsNull() {
+ GitHubRequest request = new GitHubRequest("user", "repo");
+ Optional> result = gitHubClient.fetchIssue(request, null);
+
+ assertTrue(result.isPresent());
+ assertTrue(result.get().isEmpty());
+
+ verify(webClient, never()).get();
+ }
+
+ @Test
+ @DisplayName("fetchPullRequest: возвращает пустой список, если since = null")
+ void fetchPullRequest_ShouldReturnEmptyList_WhenSinceIsNull() {
+ // Вызов метода с since = null
+ GitHubRequest request = new GitHubRequest("user", "repo");
+ Optional> result = gitHubClient.fetchPullRequest(request, null);
+
+ // Проверки
+ assertNotNull(result.get());
+ assertTrue(result.get().isEmpty());
+
+ // Проверка, что WebClient не вызывался
+ verify(webClient, never()).get();
+ }
+
+ @Test
+ @DisplayName("fetchIssue: возвращает пустой список, если since = null")
+ void fetchIssue_ShouldReturnEmptyList_WhenSinceIsNull() {
+ GitHubRequest request = new GitHubRequest("user", "repo");
+ Optional> result = gitHubClient.fetchIssue(request, null);
+ assertNotNull(result.get());
+ assertTrue(result.get().isEmpty());
+
+ verify(webClient, never()).get();
+ }
+}
diff --git a/scrapper/src/test/java/tracker/LinkUpdateProcessorTest.java b/scrapper/src/test/java/tracker/LinkUpdateProcessorTest.java
new file mode 100644
index 0000000..d7c89b6
--- /dev/null
+++ b/scrapper/src/test/java/tracker/LinkUpdateProcessorTest.java
@@ -0,0 +1,156 @@
+package tracker;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import backend.academy.scrapper.client.type.UpdateSender;
+import backend.academy.scrapper.repository.TgChatLinkRepository;
+import backend.academy.scrapper.service.LinkService;
+import backend.academy.scrapper.tracker.client.GitHubClient;
+import backend.academy.scrapper.tracker.client.StackOverFlowClient;
+import backend.academy.scrapper.tracker.response.github.GitHubResponse;
+import backend.academy.scrapper.tracker.response.github.IssueResponse;
+import backend.academy.scrapper.tracker.response.github.PullRequestResponse;
+import backend.academy.scrapper.tracker.response.stack.AnswersResponse;
+import backend.academy.scrapper.tracker.response.stack.CommentResponse;
+import backend.academy.scrapper.tracker.response.stack.QuestionResponse;
+import backend.academy.scrapper.tracker.update.LinkUpdateProcessor;
+import backend.academy.scrapper.tracker.update.dto.LinkDto;
+import backend.academy.scrapper.tracker.update.exception.BadLinkRequestException;
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+class LinkUpdateProcessorTest {
+
+ @Mock
+ private UpdateSender tgBotClient;
+
+ @Mock
+ private GitHubClient gitHubClient;
+
+ @Mock
+ private StackOverFlowClient stackOverFlowClient;
+
+ @Mock
+ private LinkService linkService;
+
+ @Mock
+ private TgChatLinkRepository tgChatLinkRepository;
+
+ @InjectMocks
+ private LinkUpdateProcessor linkUpdateProcessor;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void testUpdateLink_InvalidLink() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://invalid.com"));
+ linkDto.lastUpdated(OffsetDateTime.now());
+
+ assertThrows(BadLinkRequestException.class, () -> linkUpdateProcessor.updateLink(List.of(linkDto)));
+ }
+
+ @Test
+ void testUpdateFetchRepository() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://github.com/user/repo"));
+ linkDto.lastUpdated(OffsetDateTime.now().minusDays(1));
+
+ GitHubResponse gitHubResponse = new GitHubResponse("repo", OffsetDateTime.now());
+
+ StringBuilder result = linkUpdateProcessor.updateFetchRepository(linkDto, gitHubResponse);
+
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ void testUpdateFetchPullRequest() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://github.com/user/repo"));
+ linkDto.lastUpdated(OffsetDateTime.now().minusDays(1));
+
+ PullRequestResponse pullRequestResponse = new PullRequestResponse(
+ "PR Title", new PullRequestResponse.User("user"), OffsetDateTime.now(), "PR body");
+
+ StringBuilder result = linkUpdateProcessor.updateFetchPullRequest(linkDto, List.of(pullRequestResponse));
+
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ void testUpdateFetchIssue() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://github.com/user/repo"));
+ linkDto.lastUpdated(OffsetDateTime.now().minusDays(1));
+
+ IssueResponse issueResponse =
+ new IssueResponse("Issue Title", new IssueResponse.User("user"), OffsetDateTime.now(), "Issue body");
+
+ StringBuilder result = linkUpdateProcessor.updateFetchIssue(linkDto, List.of(issueResponse));
+
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ void testUpdateFetchQuestion() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://stackoverflow.com/questions/12345"));
+ linkDto.lastUpdated(OffsetDateTime.now().minusDays(1));
+
+ QuestionResponse.QuestionItem questionItem =
+ new QuestionResponse.QuestionItem(OffsetDateTime.now(), "Question Title");
+ QuestionResponse questionResponse = new QuestionResponse(List.of(questionItem));
+
+ StringBuilder result = linkUpdateProcessor.updateFetchQuestion(linkDto, questionResponse);
+
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ void testUpdateFetchComment() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://stackoverflow.com/questions/12345"));
+ linkDto.lastUpdated(OffsetDateTime.now().minusDays(1));
+
+ CommentResponse.Comment comment =
+ new CommentResponse.Comment(new CommentResponse.Owner("user"), OffsetDateTime.now(), "Comment body");
+ CommentResponse commentResponse = new CommentResponse(List.of(comment));
+
+ StringBuilder result = linkUpdateProcessor.updateFetchComment(linkDto, commentResponse);
+
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ void testUpdateFetchAnswers_NoUpdates() {
+ LinkDto linkDto = new LinkDto();
+ linkDto.id(1L);
+ linkDto.url(URI.create("https://stackoverflow.com/questions/12345"));
+ linkDto.lastUpdated(OffsetDateTime.now());
+
+ // Создаем пустой ответ
+ AnswersResponse answersResponse = new AnswersResponse(Collections.emptyList());
+
+ // Выполняем метод
+ StringBuilder result = linkUpdateProcessor.updateFetchAnswers(linkDto, answersResponse);
+
+ // Проверяем, что результат пустой
+ assertTrue(result.isEmpty());
+ }
+}