diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 113e9b5..343d6b6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,7 +6,10 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: edu-self-hosted + container: + image: maven:3-eclipse-temurin-24 + timeout-minutes: 10 name: Build permissions: contents: read @@ -37,7 +40,10 @@ jobs: linter: name: linter - runs-on: ubuntu-latest + runs-on: edu-self-hosted + container: + image: maven:3-eclipse-temurin-24 + timeout-minutes: 10 permissions: contents: read packages: write diff --git a/README.md b/README.md index 37364bd..83780d7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ # Link Tracker +---- + +Чтобы бот заработал нужно в переменные среды загрузить TELEGRAM_TOKEN + +---- + Проект сделан в рамках курса Академия Бэкенда. diff --git a/bot/pom.xml b/bot/pom.xml index 35558ec..4bb1fc4 100644 --- a/bot/pom.xml +++ b/bot/pom.xml @@ -6,6 +6,7 @@ backend.academy root ${revision} + bot @@ -43,11 +44,11 @@ spring-boot-starter-data-redis - - - - - + + + org.springframework.kafka + spring-kafka + @@ -87,6 +88,7 @@ spring-boot-starter-test test + io.projectreactor reactor-test @@ -112,11 +114,55 @@ kafka test - - - - - + + org.springframework.kafka + spring-kafka-test + test + + + org.glassfish.jaxb + jaxb-runtime + test + + + + commons-io + commons-io + 2.16.1 + + + io.github.resilience4j + resilience4j-spring-boot3 + + + io.github.resilience4j + resilience4j-reactor + + + + org.springframework.retry + spring-retry + + + + org.aspectj + aspectjweaver + 1.9.20.1 + + + + com.bucket4j + bucket4j-core + 8.7.0 + + + + + org.springframework.cloud + spring-cloud-contract-wiremock + 4.2.1 + + @@ -125,7 +171,6 @@ org.apache.maven.plugins maven-compiler-plugin - org.springframework.boot spring-boot-maven-plugin diff --git a/bot/src/main/java/backend/academy/bot/BotApplication.java b/bot/src/main/java/backend/academy/bot/BotApplication.java index 8cb7054..98aeb2a 100644 --- a/bot/src/main/java/backend/academy/bot/BotApplication.java +++ b/bot/src/main/java/backend/academy/bot/BotApplication.java @@ -1,11 +1,17 @@ package backend.academy.bot; +import backend.academy.bot.config.BotConfig; +import backend.academy.bot.limit.RateLimitProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableConfigurationProperties({BotConfig.class}) +@EnableConfigurationProperties({BotConfig.class, RateLimitProperties.class}) +@EnableScheduling +@EnableRetry public class BotApplication { public static void main(String[] args) { SpringApplication.run(BotApplication.class, args); diff --git a/bot/src/main/java/backend/academy/bot/LinkTrackerBot.java b/bot/src/main/java/backend/academy/bot/LinkTrackerBot.java new file mode 100644 index 0000000..d7041f2 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/LinkTrackerBot.java @@ -0,0 +1,31 @@ +package backend.academy.bot; + +import backend.academy.bot.listener.MessageListener; +import backend.academy.bot.processor.UserMessageProcessor; +import com.pengrad.telegrambot.TelegramBot; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Getter +@Component +public class LinkTrackerBot implements AutoCloseable { + + private final TelegramBot telegramBot; + private final MessageListener messageListener; + private final UserMessageProcessor userMessageProcessor; + + @PostConstruct + public void init() { + telegramBot.setUpdatesListener(messageListener); + // Регистрируем команды при запуске + userMessageProcessor.registerCommands(); + } + + @Override + public void close() { + telegramBot.shutdown(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/api/controller/UpdateController.java b/bot/src/main/java/backend/academy/bot/api/controller/UpdateController.java new file mode 100644 index 0000000..33c2287 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/controller/UpdateController.java @@ -0,0 +1,37 @@ +package backend.academy.bot.api.controller; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.notification.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@Slf4j +@RestController +public class UpdateController { + + private final NotificationService notificationService; + + @Operation(summary = "Отправить обновление") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Обновление обработано")}) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/updates") + public void update(@RequestBody @Valid LinkUpdate linkUpdate) { + log.info("Пришло обновление по ссылке"); + notificationService.sendMessage(linkUpdate); + } + + @PostMapping("/public") + public void update() { + log.info("Пришло обновление по ссылке"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/kafka/BadLink.java b/bot/src/main/java/backend/academy/bot/api/dto/kafka/BadLink.java new file mode 100644 index 0000000..f2ac361 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/kafka/BadLink.java @@ -0,0 +1,3 @@ +package backend.academy.bot.api.dto.kafka; + +public record BadLink(Long id, String url) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/request/AddLinkRequest.java b/bot/src/main/java/backend/academy/bot/api/dto/request/AddLinkRequest.java new file mode 100644 index 0000000..3175704 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/AddLinkRequest.java @@ -0,0 +1,8 @@ +package backend.academy.bot.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.util.List; + +public record AddLinkRequest( + @NotNull(message = "URL не может быть пустым") URI link, List tags, List filters) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/request/LinkUpdate.java b/bot/src/main/java/backend/academy/bot/api/dto/request/LinkUpdate.java new file mode 100644 index 0000000..a56d302 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/LinkUpdate.java @@ -0,0 +1,16 @@ +package backend.academy.bot.api.dto.request; + +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/bot/src/main/java/backend/academy/bot/api/dto/request/RemoveLinkRequest.java b/bot/src/main/java/backend/academy/bot/api/dto/request/RemoveLinkRequest.java new file mode 100644 index 0000000..bc80216 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/RemoveLinkRequest.java @@ -0,0 +1,6 @@ +package backend.academy.bot.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.net.URI; + +public record RemoveLinkRequest(@NotNull(message = "URL не может быть пустым") URI link) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/request/filter/FilterRequest.java b/bot/src/main/java/backend/academy/bot/api/dto/request/filter/FilterRequest.java new file mode 100644 index 0000000..14954b4 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/filter/FilterRequest.java @@ -0,0 +1,7 @@ +package backend.academy.bot.api.dto.request.filter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FilterRequest( + @NotBlank @Size(max = 50, message = "Длина фильтра не должна превышать 50 символов") String filter) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagLinkRequest.java b/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagLinkRequest.java new file mode 100644 index 0000000..5600cd0 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagLinkRequest.java @@ -0,0 +1,7 @@ +package backend.academy.bot.api.dto.request.tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TagLinkRequest( + @NotBlank @Size(max = 50, message = "Длина тега не должна превышать 50 символов") String tag) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagRemoveRequest.java b/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagRemoveRequest.java new file mode 100644 index 0000000..853e2bc --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/request/tag/TagRemoveRequest.java @@ -0,0 +1,10 @@ +package backend.academy.bot.api.dto.request.tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.net.URI; + +public record TagRemoveRequest( + @NotBlank @Size(max = 50, message = "Длина тега не должна превышать 50 символов") String tag, + @NotNull(message = "URL не может быть пустым") URI uri) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/ApiErrorResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/ApiErrorResponse.java new file mode 100644 index 0000000..263ca6b --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/ApiErrorResponse.java @@ -0,0 +1,11 @@ +package backend.academy.bot.api.dto.response; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public record ApiErrorResponse( + @NotBlank(message = "description не может быть пустым") String description, + @NotBlank(message = "code не может быть пустым") String code, + String exceptionName, + String exceptionMessage, + List stacktrace) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/LinkResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/LinkResponse.java new file mode 100644 index 0000000..ee2076c --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/LinkResponse.java @@ -0,0 +1,6 @@ +package backend.academy.bot.api.dto.response; + +import java.net.URI; +import java.util.List; + +public record LinkResponse(Long id, URI url, List tags, List filters) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/ListLinksResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/ListLinksResponse.java new file mode 100644 index 0000000..e63fe1b --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/ListLinksResponse.java @@ -0,0 +1,5 @@ +package backend.academy.bot.api.dto.response; + +import java.util.List; + +public record ListLinksResponse(List links, Integer size) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/TagListResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/TagListResponse.java new file mode 100644 index 0000000..59c9d47 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/TagListResponse.java @@ -0,0 +1,5 @@ +package backend.academy.bot.api.dto.response; + +import java.util.List; + +public record TagListResponse(List tags) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterListResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterListResponse.java new file mode 100644 index 0000000..b61cd0e --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterListResponse.java @@ -0,0 +1,5 @@ +package backend.academy.bot.api.dto.response.filter; + +import java.util.List; + +public record FilterListResponse(List filterList) {} diff --git a/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterResponse.java b/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterResponse.java new file mode 100644 index 0000000..64f1120 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/dto/response/filter/FilterResponse.java @@ -0,0 +1,7 @@ +package backend.academy.bot.api.dto.response.filter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FilterResponse( + Long id, @NotBlank @Size(max = 50, message = "Длина фильтра не должна превышать 50 символов") String filter) {} diff --git a/bot/src/main/java/backend/academy/bot/api/exception/GlobalExceptionHandler.java b/bot/src/main/java/backend/academy/bot/api/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8b357c6 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package backend.academy.bot.api.exception; + +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.util.Arrays; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +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(MethodArgumentNotValidException.class) + public ApiErrorResponse handleValidationException(MethodArgumentNotValidException ex) { + log.error("GlobalExceptionHandler: ОШИБКА valid: {}", ex.getMessage()); + + return new ApiErrorResponse( + "Некорректные параметры запроса", + "VALIDATION_ERROR", + ex.getClass().getSimpleName(), + ex.getMessage(), + getStackTrace(ex)); + } + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")}) + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleSerializeException(HttpMessageNotReadableException ex) { + log.error("Ошибка десcериализации: {}", ex.getMessage()); + List stacktrace = getStackTrace(ex); + return new ApiErrorResponse( + "Некорректные параметры запроса", "BAD_REQUEST", ex.getClass().getName(), ex.getMessage(), stacktrace); + } + + private List getStackTrace(Exception ex) { + return Arrays.stream(ex.getStackTrace()) + .map(StackTraceElement::toString) + .toList(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/api/exception/ResponseException.java b/bot/src/main/java/backend/academy/bot/api/exception/ResponseException.java new file mode 100644 index 0000000..0b9da24 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/api/exception/ResponseException.java @@ -0,0 +1,7 @@ +package backend.academy.bot.api.exception; + +public class ResponseException extends RuntimeException { + public ResponseException(String message) { + super(message); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/ErrorResponseHandler.java b/bot/src/main/java/backend/academy/bot/client/ErrorResponseHandler.java new file mode 100644 index 0000000..de2a0c3 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/ErrorResponseHandler.java @@ -0,0 +1,31 @@ +package backend.academy.bot.client; + +import backend.academy.bot.api.exception.ResponseException; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.reactive.function.client.ClientResponse; +import reactor.core.publisher.Mono; + +public class ErrorResponseHandler { + private static final Logger log = LoggerFactory.getLogger(ErrorResponseHandler.class); + + public static Function> handleClientError(String operation) { + return response -> createError(response, operation, true); + } + + public static Function> handleServerError(String operation) { + return response -> createError(response, operation, false); + } + + private static Mono createError( + ClientResponse response, String operation, boolean isClientError) { + return response.bodyToMono(String.class).flatMap(errorBody -> { + String errorType = isClientError ? "Ошибка" : "Серверная ошибка"; + String errorMessage = + String.format("%s при %s: %s, Body: %s", errorType, operation, response.statusCode(), errorBody); + log.error(errorMessage); + return Mono.error(new ResponseException(errorMessage)); + }); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java b/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java new file mode 100644 index 0000000..b4b1598 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/ScrapperClient.java @@ -0,0 +1,34 @@ +package backend.academy.bot.client; + +import io.netty.channel.ChannelOption; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +@Slf4j +public abstract class ScrapperClient { + + protected final WebClient webClient; + protected final WebClientProperties wcp; + + public ScrapperClient(WebClientProperties webClientProperties, WebServiceProperties webServiceProperties) { + this.wcp = webClientProperties; + // Настраиваем таймауты через HttpClient + HttpClient httpClient = HttpClient.create() + .responseTimeout(webClientProperties.responseTimeout()) // Таймаут на ответ + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) + webClientProperties.connectTimeout().toMillis()); + + log.error("BASE url: {}", webServiceProperties.scrapperUri()); + log.error( + "Propertises connection: {}, global {}", + webClientProperties.connectTimeout(), + webClientProperties.globalTimeout()); + + this.webClient = WebClient.builder() + .baseUrl(webServiceProperties.scrapperUri()) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/WebClientProperties.java b/bot/src/main/java/backend/academy/bot/client/WebClientProperties.java new file mode 100644 index 0000000..8981f90 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/WebClientProperties.java @@ -0,0 +1,37 @@ +package backend.academy.bot.client; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Validated +@Component +@Getter +@Setter +public class WebClientProperties { + + @Value("${app.webclient.timeouts.connect-timeout}") + @NotNull + @Positive + @DurationUnit(ChronoUnit.MILLIS) + private Duration connectTimeout; + + @Value("${app.webclient.timeouts.response-timeout}") + @NotNull + @Positive + @DurationUnit(ChronoUnit.MILLIS) + private Duration responseTimeout; + + @Value("${app.webclient.timeouts.global-timeout}") + @NotNull + @Positive + @DurationUnit(ChronoUnit.MILLIS) + private Duration globalTimeout; +} diff --git a/bot/src/main/java/backend/academy/bot/client/WebServiceProperties.java b/bot/src/main/java/backend/academy/bot/client/WebServiceProperties.java new file mode 100644 index 0000000..d64fbb7 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/WebServiceProperties.java @@ -0,0 +1,21 @@ +package backend.academy.bot.client; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Validated +@Component +@Getter +@Setter +public class WebServiceProperties { + + @Value("${app.link.scrapper-uri}") + @NotNull + @NotBlank + private String scrapperUri; +} diff --git a/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClient.java b/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClient.java new file mode 100644 index 0000000..4437390 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClient.java @@ -0,0 +1,11 @@ +package backend.academy.bot.client.chat; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; + +public interface ScrapperTgChatClient { + + void registerChat(Long tgChatId); + + LinkResponse deleteChat(Long tgChatId, RemoveLinkRequest request); +} diff --git a/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClientImpl.java b/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClientImpl.java new file mode 100644 index 0000000..521c22b --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/chat/ScrapperTgChatClientImpl.java @@ -0,0 +1,85 @@ +package backend.academy.bot.client.chat; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.ScrapperClient; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@Slf4j +public class ScrapperTgChatClientImpl extends ScrapperClient implements ScrapperTgChatClient { + + private static final String TG_CHAT_PATH = "tg-chat/{chatId}"; + + public ScrapperTgChatClientImpl( + WebClientProperties webClientProperties, WebServiceProperties webServiceProperties) { + super(webClientProperties, webServiceProperties); + } + + @Retry(name = "registerChat") + @CircuitBreaker(name = "ScrapperChatClient", fallbackMethod = "registerChatFallback") + @Override + public void registerChat(Long tgChatId) { + log.info("ScrapperClient registerChat!!!! {} ", tgChatId); + webClient + .post() + .uri(uriBuilder -> uriBuilder.path(TG_CHAT_PATH).build(tgChatId)) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(Void.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private void registerChatFallback(Long tgChatId, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, ex = {}", tgChatId, ex.getMessage()); + + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Ошибка сервиса"); + } + + @Retry(name = "deleteChat") + @CircuitBreaker(name = "ScrapperChatClient", fallbackMethod = "deleteChatFallback") + @Override + public LinkResponse deleteChat(Long tgChatId, RemoveLinkRequest request) { + log.info("ScrapperClient deleteLink {} ", tgChatId); + return webClient + .method(HttpMethod.DELETE) + .uri(TG_CHAT_PATH, tgChatId) + .body(Mono.just(request), RemoveLinkRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(LinkResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private void deleteChatFallback(Long tgChatId, RemoveLinkRequest request, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, request = {}, ex = {}", tgChatId, request, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Ошибка сервиса"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/exception/ServiceUnavailableCircuitException.java b/bot/src/main/java/backend/academy/bot/client/exception/ServiceUnavailableCircuitException.java new file mode 100644 index 0000000..ede9188 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/exception/ServiceUnavailableCircuitException.java @@ -0,0 +1,7 @@ +package backend.academy.bot.client.exception; + +public class ServiceUnavailableCircuitException extends RuntimeException { + public ServiceUnavailableCircuitException(String message) { + super(message); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClient.java b/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClient.java new file mode 100644 index 0000000..db9f972 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClient.java @@ -0,0 +1,14 @@ +package backend.academy.bot.client.filter; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.filter.FilterListResponse; +import backend.academy.bot.api.dto.response.filter.FilterResponse; + +public interface ScrapperFilterClient { + + FilterResponse createFilter(Long chatId, FilterRequest filterRequest); + + FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest); + + FilterListResponse getFilterList(Long id); +} diff --git a/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClientImpl.java b/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClientImpl.java new file mode 100644 index 0000000..3785c71 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/filter/ScrapperFilterClientImpl.java @@ -0,0 +1,119 @@ +package backend.academy.bot.client.filter; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.dto.response.filter.FilterListResponse; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.ScrapperClient; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@Slf4j +public class ScrapperFilterClientImpl extends ScrapperClient implements ScrapperFilterClient { + + private static final String FILTER_PATH = "/filter/{tgChatId}"; + + public ScrapperFilterClientImpl( + WebClientProperties webClientProperties, WebServiceProperties webServiceProperties) { + super(webClientProperties, webServiceProperties); + } + + @Retry(name = "createFilter") + @CircuitBreaker(name = "ScrapperFilterClient", fallbackMethod = "createFilterFallback") + @Override + public FilterResponse createFilter(Long chatId, FilterRequest filterRequest) { + log.info("=========== ScrapperClient addFilter: tgChatId={}, filter={}", chatId, filterRequest.filter()); + return webClient + .method(HttpMethod.POST) + .uri(uriBuilder -> uriBuilder.path(FILTER_PATH).build(chatId)) + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(filterRequest), FilterRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(FilterResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private FilterResponse createFilterFallback(Long chatId, FilterRequest filterRequest, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, filterRequest = {} Error: {}", chatId, filterRequest, ex.getMessage()); + + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } + + @Retry(name = "deleteFilter") + @CircuitBreaker(name = "ScrapperFilterClient", fallbackMethod = "deleteFilterFallback") + @Override + public FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest) { + log.info("ScrapperClient deleteFilter: tgChatId={}, filter={}", tgChatId, filterRequest.filter()); + return webClient + .method(HttpMethod.DELETE) + .uri(uriBuilder -> uriBuilder.path(FILTER_PATH).build(tgChatId)) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(filterRequest) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(FilterResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private FilterResponse deleteFilterFallback(Long tgChatId, FilterRequest filterRequest, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, filterRequest = {} Error: {}", tgChatId, filterRequest, ex.getMessage()); + + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } + + @Retry(name = "getFilterList") + @CircuitBreaker(name = "ScrapperFilterClient", fallbackMethod = "getFilterListFallback") + @Override + public FilterListResponse getFilterList(Long id) { + log.info("ScrapperClient getFilterList: tgChatId={}", id); + return webClient + .method(HttpMethod.GET) + .uri(uriBuilder -> uriBuilder.path(FILTER_PATH).build(id)) + .contentType(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(FilterListResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private FilterListResponse getFilterListFallback(Long id, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, Error: {}", id, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClient.java b/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClient.java new file mode 100644 index 0000000..8a11dc1 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClient.java @@ -0,0 +1,14 @@ +package backend.academy.bot.client.link; + +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; + +public interface ScrapperLinkClient { + LinkResponse trackLink(Long tgChatId, AddLinkRequest request); + + LinkResponse untrackLink(Long tgChatId, RemoveLinkRequest request); + + ListLinksResponse getListLink(Long tgChatId); +} diff --git a/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClientImpl.java b/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClientImpl.java new file mode 100644 index 0000000..cb648da --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/link/ScrapperLinkClientImpl.java @@ -0,0 +1,124 @@ +package backend.academy.bot.client.link; + +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.ScrapperClient; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@Slf4j +public class ScrapperLinkClientImpl extends ScrapperClient implements ScrapperLinkClient { + + private static final String LINK_PATH = "links/{tgChatId}"; + + public ScrapperLinkClientImpl(WebClientProperties webClientProperties, WebServiceProperties webServiceProperties) { + super(webClientProperties, webServiceProperties); + } + + @CircuitBreaker(name = "ScrapperLinkClient", fallbackMethod = "trackLinkFallback") + @Retry(name = "trackLink") + @Override + public LinkResponse trackLink(Long tgChatId, AddLinkRequest request) { + log.info("ScrapperClient trackLink {} ", tgChatId); + + return webClient + .post() + .uri(uriBuilder -> uriBuilder.path(LINK_PATH).build(tgChatId)) + .header("Tg-Chat-Id", String.valueOf(tgChatId)) + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request), AddLinkRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(LinkResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private LinkResponse trackLinkFallback(Long tgChatId, AddLinkRequest request, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, request = {} Error: {}", tgChatId, request, ex.getMessage()); + + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } + + @CircuitBreaker(name = "ScrapperLinkClient", fallbackMethod = "untrackLinkFallback") + @Retry(name = "untrackLink") + @Override + public LinkResponse untrackLink(Long tgChatId, RemoveLinkRequest request) { + log.info("ScrapperClient untrackLink {} ", tgChatId); + + return webClient + .method(HttpMethod.DELETE) + .uri(uriBuilder -> uriBuilder.path(LINK_PATH).build(tgChatId)) + .header("Tg-Chat-Id", String.valueOf(tgChatId)) + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request), RemoveLinkRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(LinkResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private LinkResponse untrackLinkFallback(Long tgChatId, RemoveLinkRequest request, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, request = {}, Error: {}", tgChatId, request, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } + + @CircuitBreaker(name = "ScrapperLinkClient", fallbackMethod = "getListLinkFallback") + @Retry(name = "untrackLink") + @Override + public ListLinksResponse getListLink(Long tgChatId) { + log.info("ScrapperClient getListLink {} ", tgChatId); + + return webClient + .get() + .uri(uriBuilder -> uriBuilder.path("links").build()) + .header("Tg-Chat-Id", String.valueOf(tgChatId)) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(ListLinksResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private ListLinksResponse getListLinkFallback(Long tgChatId, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, Error: {}", tgChatId, ex.getMessage()); + + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Сервис временно недоступен (Circuit Breaker)"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClient.java b/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClient.java new file mode 100644 index 0000000..a5ab7cc --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClient.java @@ -0,0 +1,15 @@ +package backend.academy.bot.client.tag; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.dto.response.TagListResponse; + +public interface ScrapperTagClient { + ListLinksResponse getListLinksByTag(Long tgChatId, TagLinkRequest tagLinkRequest); + + TagListResponse getAllListLinksByTag(Long tgChatId); + + LinkResponse removeTag(Long tgChatId, TagRemoveRequest tg); +} diff --git a/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClientImpl.java b/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClientImpl.java new file mode 100644 index 0000000..366cab4 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/client/tag/ScrapperTagClientImpl.java @@ -0,0 +1,120 @@ +package backend.academy.bot.client.tag; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.dto.response.TagListResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.ScrapperClient; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@Slf4j +public class ScrapperTagClientImpl extends ScrapperClient implements ScrapperTagClient { + + private static final String TAG_PATH = "tag/{tgChatId}"; + private static final String ALL_ELEMENTS_PATH = "/all"; + + public ScrapperTagClientImpl(WebClientProperties webClientProperties, WebServiceProperties webServiceProperties) { + super(webClientProperties, webServiceProperties); + } + + @CircuitBreaker(name = "ScrapperTagClient", fallbackMethod = "getListLinksByTagFallback") + @Retry(name = "getListLinksByTag") + @Override + public ListLinksResponse getListLinksByTag(Long tgChatId, TagLinkRequest tagLinkRequest) { + log.info("ScrapperClient getListLinksByTag {} ", tgChatId); + + return webClient + .method(HttpMethod.GET) + .uri(uriBuilder -> uriBuilder.path(TAG_PATH).build(tgChatId)) + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(tagLinkRequest), TagLinkRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(ListLinksResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private ListLinksResponse getListLinksByTagFallback(Long tgChatId, TagLinkRequest tagLinkRequest, Exception ex) { + log.error("Circuit ДЕФОЛТ id {}, tagLinkRequest = {}, error: {}", tgChatId, tagLinkRequest, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Ошибка сервиса"); + } + + @CircuitBreaker(name = "ScrapperTagClient", fallbackMethod = "getAllListLinksByTagFallback") + @Retry(name = "getAllListLinksByTag") + @Override + public TagListResponse getAllListLinksByTag(Long tgChatId) { + return webClient + .method(HttpMethod.GET) + .uri(uriBuilder -> uriBuilder + .path(TAG_PATH + ALL_ELEMENTS_PATH) // Путь будет "tag/{tgChatId}/all" + .build(tgChatId)) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(TagListResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private TagListResponse getAllListLinksByTagFallback(Long tgChatId, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, ex = {}", tgChatId, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Ошибка сервиса"); + } + + @CircuitBreaker(name = "ScrapperTagClient", fallbackMethod = "removeTagFallback") + @Retry(name = "removeTag") + @Override + public LinkResponse removeTag(Long tgChatId, TagRemoveRequest tg) { + log.info("ScrapperClient untrackLink: tgChatId={}, request={}", tgChatId, tg); + return webClient + .method(HttpMethod.DELETE) + .uri(uriBuilder -> uriBuilder.path(TAG_PATH).build(tgChatId)) + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(tg), TagRemoveRequest.class) + .retrieve() + .onStatus(status -> status == HttpStatus.BAD_REQUEST, response -> response.bodyToMono( + ApiErrorResponse.class) + .map(error -> new ResponseException(error.description())) + .flatMap(Mono::error)) + .bodyToMono(LinkResponse.class) + .timeout(wcp.globalTimeout()) + .block(); + } + + @SuppressWarnings("PMD.UnusedPrivateMethod") + private LinkResponse removeTagFallback(Long tgChatId, TagRemoveRequest tg, Exception ex) { + log.error("Circuit ДЕФОЛТ id = {}, tg = {}, ex = {}", tgChatId, tg, ex.getMessage()); + if (ex instanceof ResponseException) { + throw new ResponseException(ex.getMessage()); + } + throw new ServiceUnavailableCircuitException("Ошибка сервиса"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/Command.java b/bot/src/main/java/backend/academy/bot/command/Command.java new file mode 100644 index 0000000..02b9274 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/Command.java @@ -0,0 +1,21 @@ +package backend.academy.bot.command; + +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; + +public interface Command { + + String command(); + + String description(); + + SendMessage handle(Update update); + + default boolean matchesCommand(Update update) { + if (update.message().text() == null) { + return false; + } + String[] parts = update.message().text().split(" +", 2); + return parts.length > 0 && parts[0].equals(command()); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/filter/FilterCommand.java b/bot/src/main/java/backend/academy/bot/command/filter/FilterCommand.java new file mode 100644 index 0000000..af91f10 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/filter/FilterCommand.java @@ -0,0 +1,63 @@ +package backend.academy.bot.command.filter; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FilterCommand implements Command { + + private final ScrapperFilterClient scrapperFilterClient; + private final ParserMessage parserMessage; + + @Override + public String command() { + return "/filter"; + } + + @Override + public String description() { + return "Позволяет добавить фильтрацию на получение уведомлений"; + } + + @Override + public SendMessage handle(Update update) { + + Long id = update.message().chat().id(); + String filterName; + try { + filterName = parserMessage.parseMessageFilter( + update.message().text().trim(), "Некорректный формат ввода. Ожидается: /filter filterName"); + } catch (InvalidInputFormatException e) { + log.info("Не корректные поведение с /filter {}", id); + return new SendMessage(id, e.getMessage()); + } + + FilterRequest filterRequest = new FilterRequest(filterName); + + try { + scrapperFilterClient.createFilter(id, filterRequest); + return new SendMessage(id, "Фильтр успешно добавлен"); + } catch (ResponseException e) { + log.info("❌Ошибка добавления фильтра: {}", e.getMessage()); + return new SendMessage(id, "Ошибка: такой фильтр уже существует"); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/filter/FilterListCommand.java b/bot/src/main/java/backend/academy/bot/command/filter/FilterListCommand.java new file mode 100644 index 0000000..732cddf --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/filter/FilterListCommand.java @@ -0,0 +1,71 @@ +package backend.academy.bot.command.filter; + +import backend.academy.bot.api.dto.response.filter.FilterListResponse; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FilterListCommand implements Command { + + private final ScrapperFilterClient scrapperFilterClient; + private final ParserMessage parserMessage; + + @Override + public String command() { + return "/filterlist"; + } + + @Override + public String description() { + return "Выводи все фильтры"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + + try { + parserMessage.parseMessageFilterList(update.message().text().trim()); + } catch (InvalidInputFormatException e) { + log.info("Ошибка ввода /filterlist"); + return new SendMessage(id, "Ошибка: " + e.getMessage()); + } + + try { + FilterListResponse filterListResponse = scrapperFilterClient.getFilterList(id); + log.info("Мы получили ответ от backend"); + return new SendMessage(id, createMessage(filterListResponse.filterList())); + } catch (ResponseException e) { + log.info("бэк вернул ошибку"); + return new SendMessage(id, "Ошибка: " + e.getMessage()); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + } + + private String createMessage(List list) { + StringBuilder sb = new StringBuilder(); + sb.append("Фильтры blackList:\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append(") ").append(list.get(i).filter()).append("\n"); + } + return sb.toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/filter/UnFilterCommand.java b/bot/src/main/java/backend/academy/bot/command/filter/UnFilterCommand.java new file mode 100644 index 0000000..08f0dd1 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/filter/UnFilterCommand.java @@ -0,0 +1,64 @@ +package backend.academy.bot.command.filter; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UnFilterCommand implements Command { + + private final ScrapperFilterClient scrapperFilterClient; + private final ParserMessage parserMessage; + + @Override + public String command() { + return "/unfilter"; + } + + @Override + public String description() { + return "Удаление фильтров"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + String filterName; + try { + filterName = parserMessage.parseMessageFilter( + update.message().text().trim(), "Некорректный формат ввода. Ожидается: /unfilter filterName"); + } catch (InvalidInputFormatException e) { + log.info("Не корректные поведение с /unfilter {}", id); + return new SendMessage(id, e.getMessage()); + } + + FilterRequest filterRequest = new FilterRequest(filterName); + + try { + FilterResponse filterResponse = scrapperFilterClient.deleteFilter(id, filterRequest); + return new SendMessage(id, "фильтр успешно удален: " + filterResponse.filter()); + + } catch (ResponseException e) { + log.info("Ошибка добавления фильтра {}", id); + return new SendMessage(id, "Ошибка: " + e.getMessage()); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/helper/HelpCommand.java b/bot/src/main/java/backend/academy/bot/command/helper/HelpCommand.java new file mode 100644 index 0000000..319a46e --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/helper/HelpCommand.java @@ -0,0 +1,48 @@ +package backend.academy.bot.command.helper; + +import backend.academy.bot.command.Command; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class HelpCommand implements Command { + + private final List list; + private final UserStateManager userStateManager; + + @Override + public String command() { + return "/help"; + } + + @Override + public String description() { + return "Выводит список всех доступных команд"; + } + + @Override + public SendMessage handle(Update update) { + userStateManager.setUserStatus(update.message().chat().id(), UserState.WAITING_COMMAND); + log.info("Команда /help выполнена {}", update.message().chat().id()); + return new SendMessage(update.message().chat().id(), getListCommandMessage()); + } + + private String getListCommandMessage() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < list.size(); i++) { + sb.append(list.get(i).command()).append(" -- ").append(list.get(i).description()); + if (i != list.size() - 1) { + sb.append("\n"); + } + } + return sb.toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/helper/StartCommand.java b/bot/src/main/java/backend/academy/bot/command/helper/StartCommand.java new file mode 100644 index 0000000..3801d35 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/helper/StartCommand.java @@ -0,0 +1,57 @@ +package backend.academy.bot.command.helper; + +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.chat.ScrapperTgChatClient; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.command.Command; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@RequiredArgsConstructor +@Component +public class StartCommand implements Command { + + private final ScrapperTgChatClient scrapperTgChatClient; + private final UserStateManager userStateManager; + + @Override + public String command() { + return "/start"; + } + + @Override + public String description() { + return "Начинает работу бота"; + } + + @Override + public SendMessage handle(Update update) { + userStateManager.setUserStatus(update.message().chat().id(), UserState.WAITING_COMMAND); + + String message = "Привет! Используй /help чтобы увидеть все команды"; + try { + scrapperTgChatClient.registerChat(update.message().chat().id()); + } catch (ResponseException e) { + message = "Ты уже зарегистрировался :)"; + log.info( + "Не корректные поведение с регистрацией {}", + update.message().chat().id()); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + update.message().chat().id(), + "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(update.message().chat().id(), "❌ Неизвестная ошибка при добавлении фильтра"); + } + log.info("выполнилась команда /start {}", update.message().chat().id()); + + return new SendMessage(update.message().chat().id(), message); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/link/ListCommand.java b/bot/src/main/java/backend/academy/bot/command/link/ListCommand.java new file mode 100644 index 0000000..1a6841a --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/link/ListCommand.java @@ -0,0 +1,91 @@ +package backend.academy.bot.command.link; + +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@RequiredArgsConstructor +@Component +public class ListCommand implements Command { + + private final ScrapperLinkClient scrapperLinkClient; + private final UserStateManager userStateManager; + + private final RedisCacheService redisCacheService; + + @Override + public String command() { + return "/list"; + } + + @Override + public String description() { + return "Выводит список отслеживаемых ссылок"; + } + + @Override + public SendMessage handle(Update update) { + Long chatId = update.message().chat().id(); + + userStateManager.setUserStatus(chatId, UserState.WAITING_COMMAND); + + ListLinksResponse response; + try { + response = getLinks(chatId); + } catch (ResponseException e) { + log.error("Ошибка {}", e.getMessage()); + return new SendMessage(chatId.toString(), e.getMessage()); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + chatId, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(chatId, "❌ Неизвестная ошибка при добавлении фильтра"); + } + + if (response.links().isEmpty()) { + return new SendMessage(chatId.toString(), "Никакие ссылки не отслеживаются"); + } + + return new SendMessage(chatId.toString(), createMessage(response.links())); + } + + private ListLinksResponse getLinks(Long chatId) { + ListLinksResponse cached = redisCacheService.getCachedLinks(chatId); + if (cached != null) { + log.info("Достали ссылки из кэша"); + + return cached; + } + log.info("Достали ссылки из БД"); + + ListLinksResponse fresh = scrapperLinkClient.getListLink(chatId); + redisCacheService.cacheLinks(chatId, fresh); + return fresh; + } + + private String createMessage(List list) { + StringBuilder sb = new StringBuilder(); + sb.append("Отслеживаемые ссылки:\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append(")").append("\n"); + sb.append("URL:").append(list.get(i).url()).append("\n"); + sb.append("tags:").append(list.get(i).tags()).append("\n"); + sb.append("filters:").append(list.get(i).filters()).append("\n"); + } + return sb.toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/link/TrackCommand.java b/bot/src/main/java/backend/academy/bot/command/link/TrackCommand.java new file mode 100644 index 0000000..40c7eaa --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/link/TrackCommand.java @@ -0,0 +1,151 @@ +package backend.academy.bot.command.link; + +import backend.academy.bot.api.dto.kafka.BadLink; +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.kafka.client.KafkaInvalidLinkProducer; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class TrackCommand implements Command { + + private final ScrapperLinkClient scrapperLinkClient; + private final ParserMessage parserMessage; + private final UserStateManager userStateManager; + private final RedisCacheService redisCacheService; + + private final KafkaInvalidLinkProducer kafkaInvalidLinkProducer; + + @Override + public String command() { + return "/track"; + } + + @Override + public String description() { + return "Добавляет ссылку для отслеживания"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + redisCacheService.invalidateCache(id); + + switch (userStateManager.getUserState(id)) { + case WAITING_COMMAND, WAITING_URL -> { + return getUrlMessage(update); + } + + case WAITING_TAGS -> { + return getTagsMessage(update); + } + case WAITING_FILTERS -> { + + // Инициализируем теги + try { + List listFilters = parserMessage.getAdditionalAttribute( + update.message().text().trim()); + userStateManager.addUserFilters(id, listFilters); + } catch (InvalidInputFormatException e) { + log.warn( + "Пользователь не ввел фильтр {}", + update.message().chat().id()); + return new SendMessage(id, e.getMessage()); + } + + // работаем со всеми введенными данными + AddLinkRequest addLinkRequest = new AddLinkRequest( + userStateManager.getURIByUserId(id), + userStateManager.getListTagsByUserId(id), + userStateManager.getListFiltersByUserId(id)); + + LinkResponse linkResponse; + try { + linkResponse = scrapperLinkClient.trackLink(id, addLinkRequest); + } catch (ResponseException e) { + clear(id); + log.warn( + "Пользователь пытается добавить существующую ссылку: {}", + update.message().chat().id()); + return new SendMessage(id, "Такая ссылка уже добавлена, добавьте новую ссылку используя /track"); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, + "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + + String stringLog = String.format( + "Ссылка добавлена!%nURL: %s%ntags: %s%nfilters: %s", + linkResponse.url(), linkResponse.tags(), linkResponse.filters()); + + clear(id); + return new SendMessage(id, stringLog); + } + } + return new SendMessage(id, "Попробуй добавить новую ссылку"); + } + + private SendMessage getTagsMessage(Update update) { + Long id = update.message().chat().id(); + + List listTags; + try { + listTags = + parserMessage.getAdditionalAttribute(update.message().text().trim()); + } catch (InvalidInputFormatException e) { + log.warn("Ошибка при получении тегов {}", update.message().chat().id()); + return new SendMessage(id, e.getMessage()); + } + + userStateManager.addUserTags(id, listTags); + userStateManager.setUserStatus(id, UserState.WAITING_FILTERS); + log.info("Теги получены успешно {}", update.message().chat().id()); + return new SendMessage(id, "Введите фильтры через пробел для ссылки"); + } + + private void clear(Long id) { + userStateManager.clearUserStates(id); + userStateManager.clearUserInfoLinkMap(id); + } + + private SendMessage getUrlMessage(Update update) { + + Long id = update.message().chat().id(); + URI uri; + + try { + uri = parserMessage.parseUrl(update.message().text().trim(), userStateManager.getUserState(id)); + } catch (InvalidInputFormatException e) { + userStateManager.setUserStatus(id, UserState.WAITING_URL); + kafkaInvalidLinkProducer.sendInvalidLink( + new BadLink(id, update.message().text().trim().toString())); + return new SendMessage(id, e.getMessage()); + } + + userStateManager.setUserStatus(id, UserState.WAITING_TAGS); + userStateManager.addUserURI(id, uri); + + log.info("Url пользователь ввел верно {}", update.message().chat().id()); + return new SendMessage(id, "Введите теги через пробел для ссылки"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/link/UntrackCommand.java b/bot/src/main/java/backend/academy/bot/command/link/UntrackCommand.java new file mode 100644 index 0000000..1e2d000 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/link/UntrackCommand.java @@ -0,0 +1,78 @@ +package backend.academy.bot.command.link; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@RequiredArgsConstructor +@Component +public class UntrackCommand implements Command { + + private final ScrapperLinkClient scrapperLinkClient; + private final ParserMessage parserMessage; + private final UserStateManager userStateManager; + private final RedisCacheService redisCacheService; + + @Override + public String command() { + return "/untrack"; + } + + @Override + public String description() { + return "Удаляет ссылку для отслеживания"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + redisCacheService.invalidateCache(id); + + userStateManager.setUserStatus(update.message().chat().id(), UserState.WAITING_COMMAND); + + URI uri; + try { + uri = parserMessage.parseUrl(update.message().text()); + } catch (InvalidInputFormatException e) { + log.warn( + "Пользователь пытается ввести не верную ссылку для удаления: {}", + update.message().chat().id()); + return new SendMessage(id, e.getMessage()); + } + + RemoveLinkRequest removeLinkRequest = new RemoveLinkRequest(uri); + LinkResponse linkResponse; + try { + linkResponse = scrapperLinkClient.untrackLink(id, removeLinkRequest); + } catch (ResponseException e) { + log.warn( + "Пользователь пытается удалить ссылку, который нет: {}", + update.message().chat().id()); + return new SendMessage(id, "Ссылка не найдена"); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + String stringLog = String.format("Ссылка удаленна %s", linkResponse.url()); + log.info("Команда /track выполнена {}", update.message().chat().id()); + return new SendMessage(id, stringLog); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/tag/TagCommand.java b/bot/src/main/java/backend/academy/bot/command/tag/TagCommand.java new file mode 100644 index 0000000..894e7e3 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/tag/TagCommand.java @@ -0,0 +1,85 @@ +package backend.academy.bot.command.tag; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class TagCommand implements Command { + + private final ScrapperTagClient scrapperTagClient; + private final ParserMessage parserMessage; + + @Override + public String command() { + return "/tag"; // /tag name_tags -> list + } + + @Override + public String description() { + return "Позволяет выводить ссылки по тегам"; + } + + @Override + public SendMessage handle(Update update) { + String tag; + + try { + tag = parserMessage.parseMessageTag(update.message().text().trim()); + } catch (InvalidInputFormatException e) { + log.info( + "Не корректные поведение с /tag {}", update.message().chat().id()); + return new SendMessage(update.message().chat().id(), e.getMessage()); + } + + StringBuilder message = new StringBuilder("С тегом: " + tag + "\n"); + try { + ListLinksResponse listLink = + scrapperTagClient.getListLinksByTag(update.message().chat().id(), new TagLinkRequest(tag)); + if (listLink.links().isEmpty()) { + message.append("Никакие ссылки не отслеживаются"); + } else { + message.append(createMessage(listLink.links())); + } + + } catch (ResponseException e) { + log.info( + "Не корректные получение тегов из БД {}", + update.message().chat().id()); + message.append("Ошибка! попробуй еще раз"); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + update.message().chat().id(), + "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(update.message().chat().id(), "❌ Неизвестная ошибка при добавлении фильтра"); + } + + return new SendMessage(update.message().chat().id(), message.toString()); + } + + private String createMessage(List list) { + StringBuilder sb = new StringBuilder(); + sb.append("Отслеживаемые ссылки:\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append(") "); + sb.append("URL:").append(list.get(i).url()).append("\n"); + } + return sb.toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/tag/TagListCommand.java b/bot/src/main/java/backend/academy/bot/command/tag/TagListCommand.java new file mode 100644 index 0000000..b55c92a --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/tag/TagListCommand.java @@ -0,0 +1,65 @@ +package backend.academy.bot.command.tag; + +import backend.academy.bot.api.dto.response.TagListResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class TagListCommand implements Command { + + private final ScrapperTagClient scrapperTagClient; + private final ParserMessage parserMessage; + + @Override + public String command() { + return "/taglist"; + } + + @Override + public String description() { + return "Выводит все теги пользователя"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + try { + parserMessage.parseMessageTagList(update.message().text().trim()); + } catch (InvalidInputFormatException e) { + return new SendMessage(id, e.getMessage()); + } + try { + TagListResponse tagListResponse = scrapperTagClient.getAllListLinksByTag(id); + return new SendMessage(id, createMessage(tagListResponse)); + } catch (ResponseException e) { + log.error("Ошибка при /taglist {}", e.getMessage()); + return new SendMessage(id, "Ошибка попробуй еще раз"); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + } + + private String createMessage(TagListResponse tagListResponse) { + StringBuilder sb = new StringBuilder(); + sb.append("Ваши теги:\n"); + for (int i = 0; i < tagListResponse.tags().size(); i++) { + sb.append((i + 1) + ") ").append(tagListResponse.tags().get(i)).append("\n"); + } + return sb.toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/command/tag/UnTagCommand.java b/bot/src/main/java/backend/academy/bot/command/tag/UnTagCommand.java new file mode 100644 index 0000000..c84e9b6 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/command/tag/UnTagCommand.java @@ -0,0 +1,75 @@ +package backend.academy.bot.command.tag; + +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.exception.ServiceUnavailableCircuitException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.Command; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class UnTagCommand implements Command { + + private final ScrapperTagClient scrapperTagClient; + private final ParserMessage parserMessage; + private final RedisCacheService redisCacheService; + + @Override + public String command() { + return "/untag"; // /untag name_tag + } + + @Override + public String description() { + return "Удаление тега у ссылок"; + } + + @Override + public SendMessage handle(Update update) { + Long id = update.message().chat().id(); + redisCacheService.invalidateCache(id); + TagRemoveRequest tg; + try { + tg = parserMessage.parseMessageUnTag(update.message().text()); + } catch (InvalidInputFormatException e) { + return new SendMessage(id, e.getMessage()); + } + try { + return new SendMessage(id, createMessage(scrapperTagClient.removeTag(id, tg))); + } catch (ResponseException e) { + log.error("Ошибка удаление тега: {}", e.getMessage()); + return new SendMessage(id, "Ошибка: " + e.getMessage()); + } catch (ServiceUnavailableCircuitException e) { + log.error("❌Service unavailable: {}", e.getMessage()); + return new SendMessage( + id, "⚠️ Сервис временно недоступен(Circuit). Пожалуйста, попробуйте через несколько минут."); + } catch (Exception e) { + return new SendMessage(id, "❌ Неизвестная ошибка при добавлении фильтра"); + } + } + + private String createMessage(LinkResponse linkResponse) { + return new StringBuilder() + .append("Теги обновлены:") + .append("\n") + .append("Ссылка: ") + .append(linkResponse.url()) + .append("\n") + .append("Теги: ") + .append(linkResponse.tags()) + .append("\n") + .append("Фильтры: ") + .append(linkResponse.filters()) + .toString(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/config/AppConfig.java b/bot/src/main/java/backend/academy/bot/config/AppConfig.java new file mode 100644 index 0000000..14d7880 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/config/AppConfig.java @@ -0,0 +1,73 @@ +package backend.academy.bot.config; + +import com.pengrad.telegrambot.TelegramBot; +import jakarta.annotation.PreDestroy; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import okhttp3.Dispatcher; +import okhttp3.OkHttpClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@RequiredArgsConstructor +@Configuration +public class AppConfig { + + private final BotConfig botConfig; + private OkHttpClient okHttpClient; + + // Настройки пула потоков + private static final int MAX_REQUEST = 128; + private static final int MAX_REQUEST_PER_HOST = 32; + private static final int CORE_POOL_SIZE = 16; // Базовое количество потоков + private static final int MAX_POOL_SIZE = 64; // Максимальное количество потоков + private static final int KEEP_ALIVE_TIME = 60; // Время жизни неиспользуемых потоков (сек) + private static final int QUEUE_CAPACITY = 1000; // Размер очереди задач + + @Bean + public TelegramBot telegramBot() { + + // Создаем ThreadPoolExecutor с настраиваемыми параметрами + ThreadPoolExecutor executor = new ThreadPoolExecutor( + CORE_POOL_SIZE, + MAX_POOL_SIZE, + KEEP_ALIVE_TIME, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(QUEUE_CAPACITY), + new ThreadPoolExecutor.AbortPolicy()); // Политика отказа при переполнении + + // Настройка диспетчера OkHttp + Dispatcher dispatcher = new Dispatcher(executor); + dispatcher.setMaxRequests(MAX_REQUEST); + dispatcher.setMaxRequestsPerHost(MAX_REQUEST_PER_HOST); + + okHttpClient = new OkHttpClient.Builder() + .dispatcher(dispatcher) + .connectTimeout(30, TimeUnit.SECONDS) // Таймаут соединения + .readTimeout(30, TimeUnit.SECONDS) // Таймаут чтения + .writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи + .build(); + + return new TelegramBot.Builder(botConfig.telegramToken()) + .okHttpClient(okHttpClient) + .build(); + } + + @PreDestroy + public void cleanup() { + // При завершении работы приложения корректно закрываем ресурсы + if (okHttpClient != null) { + okHttpClient.dispatcher().executorService().shutdown(); + try { + if (!okHttpClient.dispatcher().executorService().awaitTermination(5, TimeUnit.SECONDS)) { + okHttpClient.dispatcher().executorService().shutdownNow(); + } + } catch (InterruptedException e) { + okHttpClient.dispatcher().executorService().shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/BotConfig.java b/bot/src/main/java/backend/academy/bot/config/BotConfig.java similarity index 70% rename from bot/src/main/java/backend/academy/bot/BotConfig.java rename to bot/src/main/java/backend/academy/bot/config/BotConfig.java index d1930e8..004386e 100644 --- a/bot/src/main/java/backend/academy/bot/BotConfig.java +++ b/bot/src/main/java/backend/academy/bot/config/BotConfig.java @@ -1,9 +1,9 @@ -package backend.academy.bot; +package backend.academy.bot.config; import jakarta.validation.constraints.NotEmpty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @Validated -@ConfigurationProperties(prefix = "app", ignoreUnknownFields = false) +@ConfigurationProperties(prefix = "app", ignoreUnknownFields = true) public record BotConfig(@NotEmpty String telegramToken) {} diff --git a/bot/src/main/java/backend/academy/bot/exception/InvalidInputFormatException.java b/bot/src/main/java/backend/academy/bot/exception/InvalidInputFormatException.java new file mode 100644 index 0000000..019599f --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/exception/InvalidInputFormatException.java @@ -0,0 +1,7 @@ +package backend.academy.bot.exception; + +public class InvalidInputFormatException extends RuntimeException { + public InvalidInputFormatException(String message) { + super(message); + } +} diff --git a/bot/src/main/java/backend/academy/bot/executor/RequestExecutor.java b/bot/src/main/java/backend/academy/bot/executor/RequestExecutor.java new file mode 100644 index 0000000..ddd3d23 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/executor/RequestExecutor.java @@ -0,0 +1,24 @@ +package backend.academy.bot.executor; + +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.response.BaseResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class RequestExecutor { + + private final TelegramBot telegramBot; + + public , R extends BaseResponse> void execute(BaseRequest request) { + if (telegramBot == null) { + log.warn("telegramBot is null"); + throw new IllegalStateException("Telegram bot is not working"); + } + telegramBot.execute(request); + } +} diff --git a/bot/src/main/java/backend/academy/bot/kafka/KafkaConsumerConfig.java b/bot/src/main/java/backend/academy/bot/kafka/KafkaConsumerConfig.java new file mode 100644 index 0000000..fccec45 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/kafka/KafkaConsumerConfig.java @@ -0,0 +1,50 @@ +package backend.academy.bot.kafka; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String consumerGroup; + + @Bean + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + Map configProps = new HashMap<>(kafkaProperties.buildConsumerProperties(null)); + + configProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroup); + configProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + configProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + configProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + configProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + configProps.put(JsonDeserializer.VALUE_DEFAULT_TYPE, LinkUpdate.class.getName()); + + return new DefaultKafkaConsumerFactory<>(configProps); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + return factory; + } +} diff --git a/bot/src/main/java/backend/academy/bot/kafka/KafkaProducerConfig.java b/bot/src/main/java/backend/academy/bot/kafka/KafkaProducerConfig.java new file mode 100644 index 0000000..cc51adb --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/kafka/KafkaProducerConfig.java @@ -0,0 +1,44 @@ +package backend.academy.bot.kafka; + +import backend.academy.bot.api.dto.kafka.BadLink; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +@Configuration +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.producer.client-id}") + private String clientId; + + @Bean + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + Map configProps = new HashMap<>(kafkaProperties.buildProducerProperties(null)); + + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ProducerConfig.CLIENT_ID_CONFIG, clientId); + + // Сериализация + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } +} diff --git a/bot/src/main/java/backend/academy/bot/kafka/KafkaTopicConfig.java b/bot/src/main/java/backend/academy/bot/kafka/KafkaTopicConfig.java new file mode 100644 index 0000000..302e6b8 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/kafka/KafkaTopicConfig.java @@ -0,0 +1,29 @@ +package backend.academy.bot.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.TopicBuilder; + +@EnableKafka +@Configuration +public class KafkaTopicConfig { + + @Value("${app.topic}") + private String topic; + + @Value("${app.topic-dlq}") + private String topicNameDlq; + + @Bean + public NewTopic topic() { + return TopicBuilder.name(topic).partitions(1).replicas(1).build(); + } + + @Bean + public NewTopic topicDlq() { + return TopicBuilder.name(topicNameDlq).partitions(1).replicas(1).build(); + } +} diff --git a/bot/src/main/java/backend/academy/bot/kafka/client/KafkaInvalidLinkProducer.java b/bot/src/main/java/backend/academy/bot/kafka/client/KafkaInvalidLinkProducer.java new file mode 100644 index 0000000..3356916 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/kafka/client/KafkaInvalidLinkProducer.java @@ -0,0 +1,29 @@ +package backend.academy.bot.kafka.client; + +import backend.academy.bot.api.dto.kafka.BadLink; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KafkaInvalidLinkProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${app.topic-dlq}") + private final String topic; + + public void sendInvalidLink(BadLink badLink) { + log.info("kafka topic: {}", topic); + try { + kafkaTemplate.send(topic, badLink); + log.info("Сообщение отправлено в kafka"); + } catch (RuntimeException e) { + log.error("Ошибка при отправки: {}", e.getMessage()); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/kafka/client/KafkaLinkUpdateListener.java b/bot/src/main/java/backend/academy/bot/kafka/client/KafkaLinkUpdateListener.java new file mode 100644 index 0000000..98365a2 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/kafka/client/KafkaLinkUpdateListener.java @@ -0,0 +1,28 @@ +package backend.academy.bot.kafka.client; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.notification.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KafkaLinkUpdateListener { + + private final NotificationService notificationService; + + @KafkaListener( + topics = "${app.topic}", + groupId = "${spring.kafka.consumer.group-id}", + properties = {"spring.json.value.default.type=backend.academy.bot.api.dto.request.LinkUpdate"}) + public void updateConsumer(LinkUpdate linkUpdate, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { + log.info("Получили информацию из топика: {}", topic); + notificationService.sendMessage(linkUpdate); + log.info("Отправили всю информацию из: {}", topic); + } +} diff --git a/bot/src/main/java/backend/academy/bot/limit/RateLimitConfig.java b/bot/src/main/java/backend/academy/bot/limit/RateLimitConfig.java new file mode 100644 index 0000000..8d7ad11 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/limit/RateLimitConfig.java @@ -0,0 +1,30 @@ +package backend.academy.bot.limit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RateLimitConfig { + + private final RateLimitProperties properties; + + @Bean + public Map ipBuckets() { + return new ConcurrentHashMap<>(); + } + + @Bean + public Bandwidth bandwidth() { + return Bandwidth.classic( + properties.capacity(), + Refill.intervally(properties.refillAmount(), Duration.ofSeconds(properties.refillSeconds()))); + } +} diff --git a/bot/src/main/java/backend/academy/bot/limit/RateLimitInterceptor.java b/bot/src/main/java/backend/academy/bot/limit/RateLimitInterceptor.java new file mode 100644 index 0000000..5e3dbdc --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/limit/RateLimitInterceptor.java @@ -0,0 +1,28 @@ +package backend.academy.bot.limit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class RateLimitInterceptor implements HandlerInterceptor { + + private final RateLimitService rateLimitService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + String clientIp = request.getRemoteAddr(); + System.err.println("Client IP: " + request.getRemoteAddr()); + if (!rateLimitService.tryConsume(clientIp)) { + response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "Rate limit exceeded"); + return false; + } + return true; + } +} diff --git a/bot/src/main/java/backend/academy/bot/limit/RateLimitProperties.java b/bot/src/main/java/backend/academy/bot/limit/RateLimitProperties.java new file mode 100644 index 0000000..6de22bb --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/limit/RateLimitProperties.java @@ -0,0 +1,6 @@ +package backend.academy.bot.limit; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "bucket4j.rate.limit") +public record RateLimitProperties(int capacity, int refillAmount, int refillSeconds) {} diff --git a/bot/src/main/java/backend/academy/bot/limit/RateLimitService.java b/bot/src/main/java/backend/academy/bot/limit/RateLimitService.java new file mode 100644 index 0000000..c3ab90d --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/limit/RateLimitService.java @@ -0,0 +1,23 @@ +package backend.academy.bot.limit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RateLimitService { + + // IP - ключ + private final Map ipBuckets; + private final Bandwidth bandwidth; + + public boolean tryConsume(String clientIp) { + Bucket bucket = ipBuckets.computeIfAbsent( + clientIp, k -> Bucket.builder().addLimit(bandwidth).build()); + + return bucket.tryConsume(1); + } +} diff --git a/bot/src/main/java/backend/academy/bot/limit/WebConfig.java b/bot/src/main/java/backend/academy/bot/limit/WebConfig.java new file mode 100644 index 0000000..807d7a2 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/limit/WebConfig.java @@ -0,0 +1,18 @@ +package backend.academy.bot.limit; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**"); + } +} diff --git a/bot/src/main/java/backend/academy/bot/listener/MessageListener.java b/bot/src/main/java/backend/academy/bot/listener/MessageListener.java new file mode 100644 index 0000000..9f5b4b4 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/listener/MessageListener.java @@ -0,0 +1,36 @@ +package backend.academy.bot.listener; + +import backend.academy.bot.executor.RequestExecutor; +import backend.academy.bot.processor.UserMessageProcessor; +import com.pengrad.telegrambot.UpdatesListener; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@RequiredArgsConstructor +@Component +public class MessageListener implements UpdatesListener { + + // Для запроса к Telegram API + private final RequestExecutor requestExecutor; + + // Обработка сообщений пользователь и какую команду вызвать + private final UserMessageProcessor userMessageProcessor; + + @Override + public int process(List updates) { + updates.forEach(update -> { + if (update.message() != null) { + SendMessage sendMessage = userMessageProcessor.process(update); + if (sendMessage != null) { + requestExecutor.execute(sendMessage); + } + } + }); + return CONFIRMED_UPDATES_ALL; + } +} diff --git a/bot/src/main/java/backend/academy/bot/message/ParserMessage.java b/bot/src/main/java/backend/academy/bot/message/ParserMessage.java new file mode 100644 index 0000000..5698ac6 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/message/ParserMessage.java @@ -0,0 +1,184 @@ +package backend.academy.bot.message; + +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.state.UserState; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +@Component +public class ParserMessage { + + private static final String URL_REGEX = "^(https?|ftp)://[^\\s/$.?#].[^\\s]*$"; + private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); + private static final String[] ALLOWED_DOMAINS = {"github.com", "stackoverflow.com"}; + + public URI parseUrl(String input, UserState userState) { + if (input == null || input.trim().isEmpty()) { + throw new InvalidInputFormatException("Входная строка не может быть пустой"); + } + + // Разделяем строку на части по пробелам + String[] parts = input.trim().split("\\s+", 2); + + // пользователь прислал просто ссылку после команды /track + if (parts.length == 1 && userState == UserState.WAITING_URL && !parts[0].equals("/track")) { + URI uri = isValidateInputUrl(parts[0]); + return uri; + } + + // пользователь прислал /track + if (parts.length == 2 && parts[0].equals("/track")) { + URI uri = isValidateInputUrl(parts[1]); + return uri; + } + + throw new InvalidInputFormatException( + "Отправьте ссылку или же " + "повторите сообщения в таком формате: /track "); + } + + public URI isValidateInputUrl(String url) { + if (!isValidUrl(url)) { + throw new InvalidInputFormatException("Введите корректный URL\nВаш URL: " + url); + } + + if (!isAllowedDomain(url)) { + throw new InvalidInputFormatException( + "Такой URL не поддерживается: " + url + "\n бот поддерживает github.com stackOverflow.com"); + } + + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new InvalidInputFormatException("Некорректное преобразования в uri: " + url); + } + return uri; + } + + public URI parseUrl(String input) { + if (input == null || input.trim().isEmpty()) { + throw new InvalidInputFormatException("Входная строка не может быть пустой."); + } + + // Разделяем строку на части по пробелам + String[] parts = input.trim().split("\\s+", 2); + + // Проверяем, что строка начинается с "/track" и содержит URL + if (parts.length != 2 || !parts[0].equals("/untrack")) { + throw new InvalidInputFormatException("Некорректный формат строки. Ожидается: /untrack "); + } + + String url = parts[1]; + + if (!isValidUrl(url)) { + throw new InvalidInputFormatException("Некорректный URL: " + url); + } + + if (!isAllowedDomain(url)) { + throw new InvalidInputFormatException( + "Такой URL не поддерживается: " + url + "\n бот поддерживает github.com stackOverflow.com"); + } + + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new InvalidInputFormatException("Некорректное преобразования в uri: " + url); + } + return uri; + } + + private boolean isValidUrl(String url) { + Matcher matcher = URL_PATTERN.matcher(url); + return matcher.matches(); + } + + private boolean isAllowedDomain(String url) { + for (String domain : ALLOWED_DOMAINS) { + if (url.contains(domain)) { + return true; + } + } + return false; + } + + public List getAdditionalAttribute(String input) { + if (input == null || input.trim().isEmpty()) { + throw new InvalidInputFormatException("Входная строка не может быть пустой"); + } + return new ArrayList<>(Arrays.asList(input.trim().split("\\s+"))); + } + + // --- Для парсинга /tag + public String parseMessageTag(String message) { + if (message == null || message.trim().isEmpty()) { + throw new InvalidInputFormatException("Некорректный формат строки. Ожидается: /tag <название>"); + } + + String[] arr = message.split(" "); + if (arr.length != 2) { + throw new InvalidInputFormatException("Некорректный формат строки. Ожидается: /tag <название>"); + } else { + return arr[1]; + } + } + + public void parseMessageTagList(String message) { + if (message == null || message.trim().isEmpty()) { + throw new InvalidInputFormatException("Некорректный формат строки. Ожидается: /taglist"); + } + String[] arr = message.split(" "); + if (arr.length > 1) { + throw new InvalidInputFormatException("Некорректный формат строки. Ожидается: /taglist"); + } + } + + public TagRemoveRequest parseMessageUnTag(String message) { + if (message == null || message.trim().isEmpty()) { + throw new InvalidInputFormatException("1. Некорректный формат строки. Ожидается: /untag name_tag uri"); + } + + String[] arr = message.split(" "); + if (arr.length != 3) { + throw new InvalidInputFormatException("2. Некорректный формат строки. Ожидается: /untag name_tag uri"); + } + + if (!"/untag".equals(arr[0])) { + throw new InvalidInputFormatException("3. Некорректный формат строки. Ожидается: /untag name_tag uri"); + } + + URI uri = isValidateInputUrl(arr[2]); + + return new TagRemoveRequest(arr[1], uri); + } + + // Для парсинга фильтров + public String parseMessageFilter(String message, String messageError) { + if (message == null || message.trim().isEmpty()) { + throw new InvalidInputFormatException(messageError); + } + String[] arr = message.split(" "); + if (arr.length != 2) { + throw new InvalidInputFormatException(messageError); + } + + return arr[1]; + } + + public void parseMessageFilterList(String message) { + if (message == null || message.trim().isEmpty()) { + throw new InvalidInputFormatException("Ошибка. Ожидается: /filterlist"); + } + String[] arr = message.split(" "); + if (arr.length != 1) { + throw new InvalidInputFormatException("Ошибка. Ожидается: /filterlist"); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/notification/MessageUpdateSender.java b/bot/src/main/java/backend/academy/bot/notification/MessageUpdateSender.java new file mode 100644 index 0000000..13a1fe1 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/notification/MessageUpdateSender.java @@ -0,0 +1,22 @@ +package backend.academy.bot.notification; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.executor.RequestExecutor; +import com.pengrad.telegrambot.request.SendMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessageUpdateSender { + + private final RequestExecutor execute; + + public void sendMessage(LinkUpdate linkUpdate) { + for (Long chatId : linkUpdate.tgChatIds()) { + SendMessage sendMessage = new SendMessage( + chatId, String.format("Обновление по ссылке: %s%n %s", linkUpdate.url(), linkUpdate.description())); + execute.execute(sendMessage); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/notification/NotificationMode.java b/bot/src/main/java/backend/academy/bot/notification/NotificationMode.java new file mode 100644 index 0000000..0aaae7f --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/notification/NotificationMode.java @@ -0,0 +1,6 @@ +package backend.academy.bot.notification; + +public enum NotificationMode { + IMMEDIATE, + DAILY_DIGEST +} diff --git a/bot/src/main/java/backend/academy/bot/notification/NotificationProperties.java b/bot/src/main/java/backend/academy/bot/notification/NotificationProperties.java new file mode 100644 index 0000000..d158a5b --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/notification/NotificationProperties.java @@ -0,0 +1,19 @@ +package backend.academy.bot.notification; + +import java.time.LocalTime; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +public class NotificationProperties { + + @Value("${app.notification.mode}") + private NotificationMode mode; + + @Value("${app.notification.daily-digest-time}") + private LocalTime digestTime; +} diff --git a/bot/src/main/java/backend/academy/bot/notification/NotificationService.java b/bot/src/main/java/backend/academy/bot/notification/NotificationService.java new file mode 100644 index 0000000..5be44ef --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/notification/NotificationService.java @@ -0,0 +1,39 @@ +package backend.academy.bot.notification; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.redis.RedisMessageService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationService { + + private final NotificationProperties properties; + private final MessageUpdateSender messageUpdateSender; + private final RedisMessageService redisMessageService; + + public void sendMessage(LinkUpdate linkUpdate) { + if (properties.mode() == NotificationMode.IMMEDIATE) { + messageUpdateSender.sendMessage(linkUpdate); + } else { + redisMessageService.addCacheLinks(linkUpdate); + } + } + + @Scheduled(cron = "${app.notification.daily-digest-cron}") + public void sendDailyDigest() { + if (properties.mode() != NotificationMode.DAILY_DIGEST) { + return; + } + List updates = redisMessageService.getCachedLinks(); + if (updates != null && !updates.isEmpty()) { + updates.forEach(messageUpdateSender::sendMessage); + redisMessageService.invalidateCache(); + } + } +} diff --git a/bot/src/main/java/backend/academy/bot/notification/SchedulerConfig.java b/bot/src/main/java/backend/academy/bot/notification/SchedulerConfig.java new file mode 100644 index 0000000..75326b0 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/notification/SchedulerConfig.java @@ -0,0 +1,15 @@ +package backend.academy.bot.notification; + +import java.time.LocalTime; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SchedulerConfig { + + @Bean + public String dailyDigestCron(NotificationProperties properties) { + LocalTime time = properties.digestTime(); + return String.format("0 %d %d * * *", time.getMinute(), time.getHour()); + } +} diff --git a/bot/src/main/java/backend/academy/bot/processor/UserMessageProcessor.java b/bot/src/main/java/backend/academy/bot/processor/UserMessageProcessor.java new file mode 100644 index 0000000..226ff39 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/processor/UserMessageProcessor.java @@ -0,0 +1,78 @@ +package backend.academy.bot.processor; + +import backend.academy.bot.command.Command; +import backend.academy.bot.command.link.TrackCommand; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.model.BotCommand; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import com.pengrad.telegrambot.request.SetMyCommands; +import com.pengrad.telegrambot.response.BaseResponse; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@RequiredArgsConstructor +@Getter +@Component +public class UserMessageProcessor { + + private final TelegramBot telegramBot; + private final List commandList; + private final UserStateManager userStateManager; + + public void registerCommands() { + List commands = commandList.stream() + .map(command -> new BotCommand(command.command(), command.description())) + .toList(); + + SetMyCommands setMyCommands = new SetMyCommands(commands.toArray(new BotCommand[0])); + BaseResponse response = telegramBot.execute(setMyCommands); + + if (response.isOk()) { + log.info("Команды успешно зарегистрированы в Telegram."); + } else { + log.error("Ошибка при регистрации команд: {}", response.description()); + } + } + + public SendMessage process(Update update) { + Long id = update.message().chat().id(); + userStateManager.createUserIfNotExist(id); + + for (Command command : commandList) { + if (command.matchesCommand(update)) { + return command.handle(update); + } + } + + // Если мы вводим url + switch (userStateManager.getUserState(id)) { + case WAITING_URL, WAITING_TAGS, WAITING_FILTERS -> { + try { + return getTrackCommand().handle(update); + } catch (IllegalStateException e) { + log.warn("Команда не найдена {}", e.getMessage()); + } + } + default -> { + userStateManager.setUserStatus(id, UserState.WAITING_URL); + // throw new IllegalStateException("Unexpected value: " + userStateManager.getUserState(id)); + } + } + + return new SendMessage(update.message().chat().id(), "Команда не найдена"); + } + + private Command getTrackCommand() { + return commandList.stream() + .filter(TrackCommand.class::isInstance) + .findFirst() + .orElseThrow(() -> new IllegalStateException("TrackCommand not found")); + } +} diff --git a/bot/src/main/java/backend/academy/bot/redis/RedisCacheService.java b/bot/src/main/java/backend/academy/bot/redis/RedisCacheService.java new file mode 100644 index 0000000..740b3df --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/redis/RedisCacheService.java @@ -0,0 +1,32 @@ +package backend.academy.bot.redis; + +import backend.academy.bot.api.dto.response.ListLinksResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisCacheService { + + private static final String CACHE_PREFIX = "bot:links"; + private final RedisTemplate redisTemplate; + + public void cacheLinks(Long chatId, ListLinksResponse response) { + redisTemplate.opsForValue().set(buildKey(chatId), response); + } + + public ListLinksResponse getCachedLinks(Long chatId) { + return (ListLinksResponse) redisTemplate.opsForValue().get(buildKey(chatId)); + } + + public void invalidateCache(Long chatId) { + redisTemplate.delete(buildKey(chatId)); + } + + private String buildKey(Long chatId) { + return CACHE_PREFIX + ":" + chatId; + } +} diff --git a/bot/src/main/java/backend/academy/bot/redis/RedisConfig.java b/bot/src/main/java/backend/academy/bot/redis/RedisConfig.java new file mode 100644 index 0000000..5185781 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/redis/RedisConfig.java @@ -0,0 +1,50 @@ +package backend.academy.bot.redis; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Value("${spring.cache.data.redis.host}") + private String redisHost; + + @Value("${spring.cache.data.redis.port}") + private int redisPort; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setPort(redisPort); + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate() { + return createRedisTemplate(redisConnectionFactory()); + } + + @Bean + public RedisTemplate> linkUpdateListRedisTemplate() { + return createRedisTemplate(redisConnectionFactory()); + } + + private RedisTemplate createRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } +} diff --git a/bot/src/main/java/backend/academy/bot/redis/RedisMessageService.java b/bot/src/main/java/backend/academy/bot/redis/RedisMessageService.java new file mode 100644 index 0000000..b55b1d6 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/redis/RedisMessageService.java @@ -0,0 +1,36 @@ +package backend.academy.bot.redis; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisMessageService { + + private static final String KEY_DIGEST = "bot:notifications"; + private final RedisTemplate> redisTemplate; + private static final long TTL_HOURS = 24; // Срок хранения + + public void addCacheLinks(LinkUpdate linkUpdate) { + synchronized (this) { + List currentList = redisTemplate.opsForValue().get(KEY_DIGEST); + if (currentList == null) { + currentList = new ArrayList<>(); + } + currentList.add(linkUpdate); + redisTemplate.opsForValue().set(KEY_DIGEST, currentList); + } + } + + public List getCachedLinks() { + return redisTemplate.opsForValue().get(KEY_DIGEST); + } + + public void invalidateCache() { + redisTemplate.delete(KEY_DIGEST); + } +} diff --git a/bot/src/main/java/backend/academy/bot/state/UserState.java b/bot/src/main/java/backend/academy/bot/state/UserState.java new file mode 100644 index 0000000..f1784e3 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/state/UserState.java @@ -0,0 +1,8 @@ +package backend.academy.bot.state; + +public enum UserState { + WAITING_COMMAND, // нормальное состояние + WAITING_URL, + WAITING_TAGS, + WAITING_FILTERS +} diff --git a/bot/src/main/java/backend/academy/bot/state/UserStateManager.java b/bot/src/main/java/backend/academy/bot/state/UserStateManager.java new file mode 100644 index 0000000..369e3d8 --- /dev/null +++ b/bot/src/main/java/backend/academy/bot/state/UserStateManager.java @@ -0,0 +1,80 @@ +package backend.academy.bot.state; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.stereotype.Component; + +@Component +public final class UserStateManager { + + private final Map userStates = new ConcurrentHashMap<>(); + + // Временное хранилище ID:InfoLink, как только добавленные теги и фильтры, очищается + private final Map userInfoLinkMap = new ConcurrentHashMap<>(); + + public boolean createUserIfNotExist(Long id) { + if (userStates.get(id) == null) { + userStates.put(id, UserState.WAITING_COMMAND); + userInfoLinkMap.put(id, new InfoLink()); + return true; + } + return false; + } + + public UserState getUserState(Long id) { + return userStates.get(id); + } + + public void setUserStatus(Long id, UserState userState) { + userStates.put(id, userState); + } + + // ------------------------------------- + public void addUserURI(Long id, URI uri) { + userInfoLinkMap.get(id).uri(uri); + } + + public void addUserTags(Long id, List tagsList) { + userInfoLinkMap.get(id).tags(tagsList); + } + + public void addUserFilters(Long id, List filtersList) { + userInfoLinkMap.get(id).filters(filtersList); + } + + // ------------------------------------------ + public URI getURIByUserId(Long userId) { + return userInfoLinkMap.get(userId).uri; + } + + public List getListTagsByUserId(Long userId) { + return userInfoLinkMap.get(userId).tags; + } + + public List getListFiltersByUserId(Long userId) { + return userInfoLinkMap.get(userId).filters; + } + + // ------------------------------------------- + public void clearUserStates(Long chatId) { + userStates.remove(chatId); + } + + public void clearUserInfoLinkMap(Long chatId) { + userInfoLinkMap.remove(chatId); + } + + @Getter + @Setter + @NoArgsConstructor + private class InfoLink { + private URI uri; + private List tags; + private List filters; + } +} diff --git a/bot/src/main/resources/application.yaml b/bot/src/main/resources/application.yaml index cf8ce64..5e2be7b 100644 --- a/bot/src/main/resources/application.yaml +++ b/bot/src/main/resources/application.yaml @@ -1,5 +1,20 @@ app: - telegram-token: ${TELEGRAM_TOKEN} # env variable + telegram-token: ${TELEGRAM_TOKEN} # env variable + link: + scrapper-uri: "http://localhost:8081" + webclient: + timeouts: + connect-timeout: 10s # 10 секунд на установку соединения + response-timeout: 10s # 10 секунд на получение ответа после установки соединения + global-timeout: 20s # 20 секунд на выполнение всего запроса (включая соединение и ответ) + + topic: "updated-topic" + topic-dlq: "dead-letter-queue" + notification: + mode: IMMEDIATE + daily-digest-time: "10:36" + daily-digest-cron: "0 36 10 * * ?" # Конвертированное время в cron-выражение + spring: application: @@ -11,6 +26,32 @@ spring: ddl-auto: validate open-in-view: false + cache: + type: redis + data: + redis: + host: localhost + port: 6379 + + kafka: + bootstrap-servers: "localhost:29092" + consumer: + auto-offset-reset: earliest + group-id: "consumer-group" + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + spring.json.use.type.headers: false + spring.json.value.default.type: backend.academy.bot.api.dto.request.LinkUpdate + producer: + client-id: "producer-DLQ" # Изменено с group-chatId на client-chatId + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: false + + server: port: 8080 @@ -18,3 +59,85 @@ springdoc: swagger-ui: enabled: true path: /swagger-ui + + + +resilience4j.retry: + configs: + default: + max-attempts: 3 + wait-duration: 3s + retry-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException.InternalServerError + - org.springframework.web.reactive.function.client.WebClientRequestException + - org.springframework.web.client.HttpServerErrorException + - java.util.concurrent.TimeoutException + - io.netty.channel.ConnectTimeoutException + - java.net.ConnectException + ignore-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest + instances: + createFilter: + base-config: default + registerChat: + base-config: default + deleteChat: + base-config: default + trackLink: + base-config: default + untrackLink: + base-config: default + getListLink: + base-config: default + getListLinksByTag: + base-config: default + getAllListLinksByTag: + base-config: default + removeTag: + base-config: default + deleteFilter: + base-config: default + getFilterList: + base-config: default + retry-aspect-order: 2 + +resilience4j.circuitbreaker: + configs: + default: + 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: "10s" + ignore-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest + - org.springframework.web.server.ResponseStatusException + instances: + ScrapperFilterClient: + base-config: default + ScrapperChatClient: + base-config: default + ScrapperTagClient: + base-config: default + ScrapperLinkClient: + base-config: default + circuit-breaker-aspect-order: 1 + + +bucket4j: + rate: + limit: + capacity: 50 # Максимальное количество запросов + refill-amount: 50 # Количество токенов для пополнения + refill-seconds: 60 # Интервал пополнения в секундах (например, 60 = 1 минута) + + + + +#logging: +# structured: +# format: +# file: ecs +# console: ecs +# level: +# root: INFO diff --git a/bot/src/main/resources/open-api/bot-api.yaml b/bot/src/main/resources/open-api/bot-api.yaml new file mode 100644 index 0000000..1e609bd --- /dev/null +++ b/bot/src/main/resources/open-api/bot-api.yaml @@ -0,0 +1,59 @@ +openapi: 3.1.0 +info: + title: Bot API + version: 1.0.0 + contact: + name: Alexander Biryukov + url: https://github.com +paths: + /updates: + post: + summary: Отправить обновление + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LinkUpdate' + required: true + responses: + '200': + description: Обновление обработано + '400': + description: Некорректные параметры запроса + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorResponse' +components: + schemas: + ApiErrorResponse: + type: object + properties: + description: + type: string + code: + type: string + exceptionName: + type: string + exceptionMessage: + type: string + stacktrace: + type: array + items: + type: string + LinkUpdate: + type: object + properties: + id: + type: integer + format: int64 + url: + type: string + format: uri + description: + type: string + tgChatIds: + type: array + items: + type: integer + format: int64 diff --git a/bot/src/test/java/backend/academy/bot/BotApplicationTests.java b/bot/src/test/java/backend/academy/bot/BotApplicationTests.java deleted file mode 100644 index 578345e..0000000 --- a/bot/src/test/java/backend/academy/bot/BotApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package backend.academy.bot; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; - -@Import(TestcontainersConfiguration.class) -@SpringBootTest -class BotApplicationTests { - - @Test - void contextLoads() {} -} diff --git a/bot/src/test/java/backend/academy/bot/TestApplication.java b/bot/src/test/java/backend/academy/bot/TestApplication.java index f1d792a..1dbe181 100644 --- a/bot/src/test/java/backend/academy/bot/TestApplication.java +++ b/bot/src/test/java/backend/academy/bot/TestApplication.java @@ -1,6 +1,7 @@ package backend.academy.bot; import org.springframework.boot.SpringApplication; +import org.testcontainers.utility.TestcontainersConfiguration; public class TestApplication { diff --git a/bot/src/test/java/backend/academy/bot/TestcontainersConfiguration.java b/bot/src/test/java/backend/academy/bot/TestcontainersConfiguration.java deleted file mode 100644 index 5d0c21b..0000000 --- a/bot/src/test/java/backend/academy/bot/TestcontainersConfiguration.java +++ /dev/null @@ -1,28 +0,0 @@ -package backend.academy.bot; - -import org.springframework.boot.devtools.restart.RestartScope; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.kafka.KafkaContainer; -import org.testcontainers.utility.DockerImageName; - -// isolated from the "scrapper" module's containers! -@TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration { - - @Bean - @RestartScope - @ServiceConnection(name = "redis") - GenericContainer redisContainer() { - return new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); - } - - @Bean - @RestartScope - @ServiceConnection - KafkaContainer kafkaContainer() { - return new KafkaContainer("apache/kafka-native:3.8.1").withExposedPorts(9092); - } -} diff --git a/bot/src/test/java/backend/academy/bot/api/controller/UpdateControllerTest.java b/bot/src/test/java/backend/academy/bot/api/controller/UpdateControllerTest.java new file mode 100644 index 0000000..019bc7f --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/api/controller/UpdateControllerTest.java @@ -0,0 +1,71 @@ +package backend.academy.bot.api.controller; + +import static org.assertj.core.api.Fail.fail; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.notification.NotificationService; +import java.net.URI; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@ExtendWith(MockitoExtension.class) +public class UpdateControllerTest { + + @Mock + private NotificationService notificationService; + + private UpdateController updateController; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + updateController = new UpdateController(notificationService); + } + + @Test + @DisplayName("Успешная обработка обновления ссылки") + void update_ShouldProcessValidUpdate() { + // Arrange + LinkUpdate linkUpdate = new LinkUpdate( + 123L, URI.create("https://www.example.com"), "Some description", Collections.emptyList()); + + doNothing().when(notificationService).sendMessage(linkUpdate); + + // Act & Assert + assertDoesNotThrow(() -> updateController.update(linkUpdate)); + verify(notificationService, times(1)).sendMessage(linkUpdate); + } + + @Test + @DisplayName("Проверка аннотаций контроллера") + void controller_ShouldHaveCorrectAnnotations() { + // Проверяем аннотации класса + assertNotNull(UpdateController.class.getAnnotation(RestController.class)); + + // Проверяем аннотации метода + try { + var method = UpdateController.class.getMethod("update", LinkUpdate.class); + assertNotNull(method.getAnnotation(PostMapping.class)); + assertEquals("/updates", method.getAnnotation(PostMapping.class).value()[0]); + assertNotNull(method.getAnnotation(ResponseStatus.class)); + assertEquals( + HttpStatus.OK, method.getAnnotation(ResponseStatus.class).value()); + } catch (NoSuchMethodException e) { + fail("Метод update не найден"); + } + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/HelperUtils.java b/bot/src/test/java/backend/academy/bot/client/HelperUtils.java new file mode 100644 index 0000000..c8a3209 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/HelperUtils.java @@ -0,0 +1,20 @@ +package backend.academy.bot.client; + +import java.time.Duration; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class HelperUtils { + + // Вспомогательный метод для парсинга Duration + public static Duration parseDuration(String durationStr) { + if (durationStr.startsWith("PT")) { + return Duration.parse(durationStr); + } + if (durationStr.endsWith("s")) { + long seconds = Long.parseLong(durationStr.substring(0, durationStr.length() - 1)); + return Duration.ofSeconds(seconds); + } + throw new IllegalArgumentException("Invalid duration format: " + durationStr); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/WireMockTestUtil.java b/bot/src/test/java/backend/academy/bot/client/WireMockTestUtil.java new file mode 100644 index 0000000..085b050 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/WireMockTestUtil.java @@ -0,0 +1,25 @@ +package backend.academy.bot.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/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplCircuitBreakerTest.java b/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplCircuitBreakerTest.java new file mode 100644 index 0000000..afac03b --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplCircuitBreakerTest.java @@ -0,0 +1,194 @@ +package backend.academy.bot.client.chat; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.net.URI; +import java.time.Duration; +import java.util.Properties; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@EnableRetry +public class ScrapperTgChatClientImplCircuitBreakerTest { + private static final int FIXED_PORT = 8081; + private static ScrapperTgChatClientImpl originalClient; + private static ScrapperTgChatClient decoratedClient; + private static CircuitBreaker circuitBreaker; + private static Retry retry; + + @BeforeAll + static void setup() { + // 1. Запуск WireMock + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + originalClient = new ScrapperTgChatClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + + // CircuitBreaker конфигурация + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.sliding-window-size"))) + .minimumNumberOfCalls(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.minimum-number-of-calls"))) + .failureRateThreshold(Float.parseFloat( + properties.getProperty("resilience4j.circuitbreaker.configs.default.failure-rate-threshold"))) + .waitDurationInOpenState(HelperUtils.parseDuration(properties.getProperty( + "resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state"))) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperChatClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + private static ScrapperTgChatClient createDecoratedClient( + ScrapperTgChatClientImpl client, Retry retry, CircuitBreaker circuitBreaker) { + + return new ScrapperTgChatClient() { + @Override + public void registerChat(Long tgChatId) { + Supplier supplier = () -> { + client.registerChat(tgChatId); + return null; + }; + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + throw new RuntimeException(e); + } + } + + @Override + public LinkResponse deleteChat(Long tgChatId, RemoveLinkRequest request) { + Supplier supplier = () -> client.deleteChat(tgChatId, request); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + }; + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @BeforeEach + void setUpEach() { + // Создаем новый CircuitBreaker перед каждым тестом + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(1) + .minimumNumberOfCalls(1) + .failureRateThreshold(100) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperChatClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + @Test + @DisplayName("registerChat: CircuitBreaker открывается после 3 неудачных попыток") + void registerChatShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + + assertThrows(WebClientResponseException.class, () -> decoratedClient.registerChat(123L)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.registerChat(123L)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, postRequestedFor(urlPathMatching("/tg-chat/123"))); + } + + @Test + @DisplayName("deleteChat: CircuitBreaker открывается после 3 неудачных попыток") + void deleteChatShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + + RemoveLinkRequest request = new RemoveLinkRequest(URI.create("https://github.com")); + + assertThrows(WebClientResponseException.class, () -> decoratedClient.deleteChat(123L, request)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.deleteChat(123L, request)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, deleteRequestedFor(urlPathMatching("/tg-chat/123"))); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplRetryTest.java b/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplRetryTest.java new file mode 100644 index 0000000..514a632 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/chat/ScrapperTgChatClientImplRetryTest.java @@ -0,0 +1,131 @@ +package backend.academy.bot.client.chat; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import com.github.tomakehurst.wiremock.common.Json; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Properties; +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.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@EnableRetry +public class ScrapperTgChatClientImplRetryTest { + private static final int FIXED_PORT = 8081; + private static ScrapperTgChatClientImpl client; + private static Retry retry; + + @BeforeAll + static void setup() { + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + client = new ScrapperTgChatClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @Test + @DisplayName("registerChat: Обработка исключения Server") + void registerChat_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows(WebClientResponseException.class, () -> client.registerChat(123L)); + } + + @Test + @DisplayName("registerChat: Обработка исключения ResponseException именно ошибки Scrapper") + void registerChat_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows(ResponseException.class, () -> client.registerChat(123L)); + } + + @Test + @DisplayName("deleteChat: Обработка исключения Server") + void deleteChat_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + RemoveLinkRequest request = new RemoveLinkRequest(URI.create("https://github.com")); + + assertThrows(WebClientResponseException.class, () -> client.deleteChat(123L, request)); + } + + @Test + @DisplayName("deleteChat: Обработка исключения ResponseException именно ошибки Scrapper") + void deleteChat_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tg-chat/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + RemoveLinkRequest request = new RemoveLinkRequest(URI.create("https://github.com")); + + assertThrows(ResponseException.class, () -> client.deleteChat(123L, request)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplCircuitBreakerTest.java b/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplCircuitBreakerTest.java new file mode 100644 index 0000000..6e36cc5 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplCircuitBreakerTest.java @@ -0,0 +1,242 @@ +package backend.academy.bot.client.filter; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.filter.FilterListResponse; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.time.Duration; +import java.util.Properties; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperFilterClientImplCircuitBreakerTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperFilterClientImpl originalClient; + private static ScrapperFilterClient decoratedClient; + private static CircuitBreaker circuitBreaker; + private static Retry retry; + + @BeforeAll + static void setup() { + // 1. Запуск WireMock + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + originalClient = new ScrapperFilterClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + + // CircuitBreaker конфигурация + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.sliding-window-size"))) + .minimumNumberOfCalls(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.minimum-number-of-calls"))) + .failureRateThreshold(Float.parseFloat( + properties.getProperty("resilience4j.circuitbreaker.configs.default.failure-rate-threshold"))) + .waitDurationInOpenState(HelperUtils.parseDuration(properties.getProperty( + "resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state"))) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperChatClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + private static ScrapperFilterClient createDecoratedClient( + ScrapperFilterClientImpl client, Retry retry, CircuitBreaker circuitBreaker) { + + return new ScrapperFilterClient() { + + @Override + public FilterResponse createFilter(Long tgChatId, FilterRequest filterRequest) { + Supplier supplier = () -> client.createFilter(tgChatId, filterRequest); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest) { + Supplier supplier = () -> client.deleteFilter(tgChatId, filterRequest); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public FilterListResponse getFilterList(Long id) { + Supplier supplier = () -> client.getFilterList(id); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + }; + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @BeforeEach + void setUpEach() { + // Создаем новый CircuitBreaker перед каждым тестом + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(1) + .minimumNumberOfCalls(1) + .failureRateThreshold(100) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperFilterClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + @Test + @DisplayName("createFilter: CircuitBreaker открывается после 3 неудачных попыток") + void createFilter_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + + assertThrows( + WebClientResponseException.class, + () -> decoratedClient.createFilter(123L, new FilterRequest("testFilter"))); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows( + CallNotPermittedException.class, + () -> decoratedClient.createFilter(123L, new FilterRequest("testFilter"))); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, postRequestedFor(urlPathMatching("/filter/123"))); + } + + @Test + @DisplayName("deleteFilter: CircuitBreaker открывается после 3 неудачных попыток") + void deleteFilter_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + + assertThrows( + WebClientResponseException.class, + () -> decoratedClient.deleteFilter(123L, new FilterRequest("testFilter"))); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows( + CallNotPermittedException.class, + () -> decoratedClient.deleteFilter(123L, new FilterRequest("testFilter"))); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, deleteRequestedFor(urlPathMatching("/filter/123"))); + } + + @Test + @DisplayName("getFilterList: CircuitBreaker открывается после 3 неудачных попыток") + void getFilterList_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + + assertThrows(WebClientResponseException.class, () -> decoratedClient.getFilterList(123L)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.getFilterList(123L)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, getRequestedFor(urlPathMatching("/filter/123"))); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplRetryTest.java b/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplRetryTest.java new file mode 100644 index 0000000..9f0d113 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/filter/ScrapperFilterClientImplRetryTest.java @@ -0,0 +1,164 @@ +package backend.academy.bot.client.filter; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import com.github.tomakehurst.wiremock.common.Json; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.time.Duration; +import java.util.List; +import java.util.Properties; +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.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperFilterClientImplRetryTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperFilterClientImpl client; + private static Retry retry; + + @BeforeAll + static void setup() { + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + client = new ScrapperFilterClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @Test + @DisplayName("createFilter: Обработка исключения Server") + void createFilter_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + FilterRequest filterRequest = new FilterRequest("Some Filter"); + + assertThrows(WebClientResponseException.class, () -> client.createFilter(123L, filterRequest)); + } + + @Test + @DisplayName("createFilter: Обработка исключения ResponseException именно ошибки Scrapper") + void createFilter_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/filter/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + FilterRequest filterRequest = new FilterRequest("Some Filter"); + + assertThrows(ResponseException.class, () -> client.createFilter(123L, filterRequest)); + } + + @Test + @DisplayName("deleteFilter: Обработка исключения Server") + void deleteFilter_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + FilterRequest filterRequest = new FilterRequest("Some Filter"); + + assertThrows(WebClientResponseException.class, () -> client.deleteFilter(123L, filterRequest)); + } + + @Test + @DisplayName("deleteFilter: Обработка исключения ResponseException именно ошибки Scrapper") + void deleteFilter_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/filter/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + FilterRequest filterRequest = new FilterRequest("Some Filter"); + + assertThrows(ResponseException.class, () -> client.deleteFilter(123L, filterRequest)); + } + + @Test + @DisplayName("getFilterList: Обработка исключения Server") + void getFilterList_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/filter/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows(WebClientResponseException.class, () -> client.getFilterList(123L)); + } + + @Test + @DisplayName("getFilterList: Обработка исключения ResponseException именно ошибки Scrapper") + void getFilterList_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/filter/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows(ResponseException.class, () -> client.getFilterList(123L)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplCircuitBreakerTest.java b/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplCircuitBreakerTest.java new file mode 100644 index 0000000..45b6bba --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplCircuitBreakerTest.java @@ -0,0 +1,240 @@ +package backend.academy.bot.client.link; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +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 java.util.Properties; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperLinkClientImplCircuitBreakerTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperLinkClientImpl originalClient; + private static ScrapperLinkClient decoratedClient; + private static CircuitBreaker circuitBreaker; + private static Retry retry; + + @BeforeAll + static void setup() { + // 1. Запуск WireMock + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + originalClient = new ScrapperLinkClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + + // CircuitBreaker конфигурация + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.sliding-window-size"))) + .minimumNumberOfCalls(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.minimum-number-of-calls"))) + .failureRateThreshold(Float.parseFloat( + properties.getProperty("resilience4j.circuitbreaker.configs.default.failure-rate-threshold"))) + .waitDurationInOpenState(HelperUtils.parseDuration(properties.getProperty( + "resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state"))) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperChatClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + private static ScrapperLinkClient createDecoratedClient( + ScrapperLinkClientImpl client, Retry retry, CircuitBreaker circuitBreaker) { + + return new ScrapperLinkClient() { + + @Override + public LinkResponse trackLink(Long tgChatId, AddLinkRequest request) { + Supplier supplier = () -> client.trackLink(tgChatId, request); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public LinkResponse untrackLink(Long tgChatId, RemoveLinkRequest request) { + Supplier supplier = () -> client.untrackLink(tgChatId, request); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public ListLinksResponse getListLink(Long tgChatId) { + Supplier supplier = () -> client.getListLink(tgChatId); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + }; + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @BeforeEach + void setUpEach() { + // Создаем новый CircuitBreaker перед каждым тестом + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(1) + .minimumNumberOfCalls(1) + .failureRateThreshold(100) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperLinkClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + @Test + @DisplayName("trackLink: CircuitBreaker открывается после 3 неудачных попыток") + void trackLink_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/links/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + AddLinkRequest addLinkRequest = + new AddLinkRequest(URI.create("https://github.com"), Collections.emptyList(), Collections.emptyList()); + + assertThrows(WebClientResponseException.class, () -> decoratedClient.trackLink(123L, addLinkRequest)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.trackLink(123L, addLinkRequest)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, postRequestedFor(urlPathMatching("/links/123"))); + } + + @Test + @DisplayName("untrackLink: CircuitBreaker открывается после 3 неудачных попыток") + void untrackLink_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/links/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + assertThrows( + WebClientResponseException.class, + () -> decoratedClient.untrackLink(123L, new RemoveLinkRequest(URI.create("https://github.com")))); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows( + CallNotPermittedException.class, + () -> decoratedClient.untrackLink(123L, new RemoveLinkRequest(URI.create("https://github.com")))); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, deleteRequestedFor(urlPathMatching("/links/123"))); + } + + @Test + @DisplayName("getListLink: CircuitBreaker открывается после 3 неудачных попыток") + void getListLink_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/links")).willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + assertThrows(WebClientResponseException.class, () -> decoratedClient.getListLink(123L)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.getListLink(123L)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, getRequestedFor(urlPathMatching("/links"))); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplRetryTest.java b/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplRetryTest.java new file mode 100644 index 0000000..adcbc37 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/link/ScrapperLinkClientImplRetryTest.java @@ -0,0 +1,168 @@ +package backend.academy.bot.client.link; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import com.github.tomakehurst.wiremock.common.Json; +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 java.util.List; +import java.util.Properties; +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.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperLinkClientImplRetryTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperLinkClientImpl client; + private static Retry retry; + + @BeforeAll + static void setup() { + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + client = new ScrapperLinkClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @Test + @DisplayName("trackLink: Обработка исключения Server") + void trackLink_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/links/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + AddLinkRequest addLinkRequest = + new AddLinkRequest(URI.create("https://github.com"), Collections.emptyList(), Collections.emptyList()); + assertThrows(WebClientResponseException.class, () -> client.trackLink(123L, addLinkRequest)); + } + + @Test + @DisplayName("trackLink: Обработка исключения ResponseException именно ошибки Scrapper") + void trackLink_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(post(urlPathMatching("/links/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + AddLinkRequest addLinkRequest = + new AddLinkRequest(URI.create("https://github.com"), Collections.emptyList(), Collections.emptyList()); + + assertThrows(ResponseException.class, () -> client.trackLink(123L, addLinkRequest)); + } + + @Test + @DisplayName("untrackLink: Обработка исключения Server") + void untrackLink_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/links/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows( + WebClientResponseException.class, + () -> client.untrackLink(123L, new RemoveLinkRequest(URI.create("https://github.com")))); + } + + @Test + @DisplayName("untrackLink: Обработка исключения ResponseException именно ошибки Scrapper") + void untrackLink_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/links/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows( + ResponseException.class, + () -> client.untrackLink(123L, new RemoveLinkRequest(URI.create("https://github.com")))); + } + + @Test + @DisplayName("getListLink: Обработка исключения Server") + void getListLink_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/links/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows(WebClientResponseException.class, () -> client.getListLink(123L)); + } + + @Test + @DisplayName("getListLink: Обработка исключения ResponseException именно ошибки Scrapper") + void getListLink_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/links")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows(ResponseException.class, () -> client.getListLink(123L)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplCircuitBreakerTest.java b/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplCircuitBreakerTest.java new file mode 100644 index 0000000..d626658 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplCircuitBreakerTest.java @@ -0,0 +1,234 @@ +package backend.academy.bot.client.tag; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.dto.response.TagListResponse; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.net.URI; +import java.time.Duration; +import java.util.Properties; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperTagClientImplCircuitBreakerTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperTagClientImpl originalClient; + private static ScrapperTagClient decoratedClient; + private static CircuitBreaker circuitBreaker; + private static Retry retry; + + @BeforeAll + static void setup() { + // 1. Запуск WireMock + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + originalClient = new ScrapperTagClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + + // CircuitBreaker конфигурация + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.sliding-window-size"))) + .minimumNumberOfCalls(Integer.parseInt( + properties.getProperty("resilience4j.circuitbreaker.configs.default.minimum-number-of-calls"))) + .failureRateThreshold(Float.parseFloat( + properties.getProperty("resilience4j.circuitbreaker.configs.default.failure-rate-threshold"))) + .waitDurationInOpenState(HelperUtils.parseDuration(properties.getProperty( + "resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state"))) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperChatClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + private static ScrapperTagClient createDecoratedClient( + ScrapperTagClientImpl client, Retry retry, CircuitBreaker circuitBreaker) { + + return new ScrapperTagClient() { + + @Override + public ListLinksResponse getListLinksByTag(Long tgChatId, TagLinkRequest tagLinkRequest) { + Supplier supplier = () -> client.getListLinksByTag(tgChatId, tagLinkRequest); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public TagListResponse getAllListLinksByTag(Long tgChatId) { + Supplier supplier = () -> client.getAllListLinksByTag(tgChatId); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + + @Override + public LinkResponse removeTag(Long tgChatId, TagRemoveRequest tg) { + Supplier supplier = () -> client.removeTag(tgChatId, tg); + + Supplier decorated = + CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, supplier)); + + try { + return decorated.get(); + } catch (Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(e); + } + } + }; + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @BeforeEach + void setUpEach() { + // Создаем новый CircuitBreaker перед каждым тестом + CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(1) + .minimumNumberOfCalls(1) + .failureRateThreshold(100) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .build(); + circuitBreaker = CircuitBreaker.of("ScrapperTagClient", cbConfig); + + decoratedClient = createDecoratedClient(originalClient, retry, circuitBreaker); + } + + @Test + @DisplayName("getListLinksByTag: CircuitBreaker открывается после 3 неудачных попыток") + void getListLinksByTag_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123")).willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + TagLinkRequest tagLinkRequest = new TagLinkRequest("testTag"); + + assertThrows(WebClientResponseException.class, () -> decoratedClient.getListLinksByTag(123L, tagLinkRequest)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.getListLinksByTag(123L, tagLinkRequest)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, getRequestedFor(urlPathMatching("/tag/123"))); + } + + @Test + @DisplayName("getAllListLinksByTag: CircuitBreaker открывается после 3 неудачных попыток") + void getAllListLinksByTag_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123")).willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + assertThrows(WebClientResponseException.class, () -> decoratedClient.getAllListLinksByTag(123L)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.getAllListLinksByTag(123L)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, getRequestedFor(urlPathMatching("/tag/123"))); + } + + @Test + @DisplayName("removeTag: CircuitBreaker открывается после 3 неудачных попыток") + void removeTag_ShouldOpenCircuitAfterThreeFailures() { + // Настраиваем постоянные 500 ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tag/123")) + .willReturn(aResponse().withStatus(500))); + + // Первые 3 вызова (должны пройти через Retry) + TagRemoveRequest tagRemoveRequest = new TagRemoveRequest("testTag", URI.create("https://github.com")); + + assertThrows(WebClientResponseException.class, () -> decoratedClient.removeTag(123L, tagRemoveRequest)); + + // Проверяем что CircuitBreaker открыт + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThrows(CallNotPermittedException.class, () -> decoratedClient.removeTag(123L, tagRemoveRequest)); + + // Проверяем что было ровно 3 реальных вызова + WireMockTestUtil.getWireMockServer().verify(3, deleteRequestedFor(urlPathMatching("/tag/123"))); + } +} diff --git a/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplRetryTest.java b/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplRetryTest.java new file mode 100644 index 0000000..1de9b5a --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/client/tag/ScrapperTagClientImplRetryTest.java @@ -0,0 +1,162 @@ +package backend.academy.bot.client.tag; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.ApiErrorResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.HelperUtils; +import backend.academy.bot.client.WebClientProperties; +import backend.academy.bot.client.WebServiceProperties; +import backend.academy.bot.client.WireMockTestUtil; +import com.github.tomakehurst.wiremock.common.Json; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Properties; +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.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ScrapperTagClientImplRetryTest { + + private static final int FIXED_PORT = 8081; + private static ScrapperTagClientImpl client; + private static Retry retry; + + @BeforeAll + static void setup() { + WireMockTestUtil.setUp(FIXED_PORT); + + // 2. Загрузка конфигурации из YAML + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-test.yaml")); + Properties properties = yaml.getObject(); + + // 3. Создание свойств с конфигурацией + WebClientProperties webClientProps = new WebClientProperties(); + webClientProps.connectTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.connect-timeout"))); + webClientProps.responseTimeout( + Duration.parse(properties.getProperty("app.webclient.timeouts.response-timeout"))); + webClientProps.globalTimeout(Duration.parse(properties.getProperty("app.webclient.timeouts.global-timeout"))); + + WebServiceProperties webServiceProps = new WebServiceProperties(); + webServiceProps.scrapperUri(properties.getProperty("app.link.scrapper-uri")); + + client = new ScrapperTagClientImpl(webClientProps, webServiceProps); + + // 4. Инициализация Resilience4j из конфигурации + // Retry конфигурация + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts( + Integer.parseInt(properties.getProperty("resilience4j.retry.configs.default.max-attempts"))) + .waitDuration(HelperUtils.parseDuration( + properties.getProperty("resilience4j.retry.configs.default.wait-duration"))) + .retryExceptions(WebClientResponseException.class) // Можно расширить список + .build(); + retry = Retry.of("registerChat", retryConfig); + } + + @AfterAll + static void tearDown() { + WireMockTestUtil.tearDown(); + } + + @Test + @DisplayName("getListLinksByTag: Обработка исключения Server") + void getListLinksByTag_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows( + WebClientResponseException.class, () -> client.getListLinksByTag(123L, new TagLinkRequest("some tag"))); + } + + @Test + @DisplayName("getListLinksByTag: Обработка исключения ResponseException именно ошибки Scrapper") + void getListLinksByTag_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows(ResponseException.class, () -> client.getListLinksByTag(123L, new TagLinkRequest("some tag"))); + } + + @Test + @DisplayName("getAllListLinksByTag: Обработка исключения Server") + void getAllListLinksByTag_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123/all")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows(WebClientResponseException.class, () -> client.getAllListLinksByTag(123L)); + } + + @Test + @DisplayName("getAllListLinksByTag: Обработка исключения ResponseException именно ошибки Scrapper") + void getAllListLinksByTag_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(get(urlPathMatching("/tag/123/all")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows(ResponseException.class, () -> client.getAllListLinksByTag(123L)); + } + + @Test + @DisplayName("removeTag: Обработка исключения Server") + void removeTag_shouldSuccessWhenServerReturnsError() { + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tag/123")) + .willReturn(aResponse().withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()))); + + assertThrows( + WebClientResponseException.class, + () -> client.removeTag(123L, new TagRemoveRequest("Some", URI.create("http://github.com")))); + } + + @Test + @DisplayName("removeTag: Обработка исключения ResponseException именно ошибки Scrapper") + void removeTag_shouldSuccessWhenServerReturnsOk() { + ApiErrorResponse errorResponse = + new ApiErrorResponse("Invalid request", "400", "BadRequestException", "Invalid chat ID", List.of()); + + // Настраиваем WireMock для возврата 400 с телом ошибки + WireMockTestUtil.getWireMockServer() + .stubFor(delete(urlPathMatching("/tag/123")) + .willReturn(aResponse() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withHeader("Content-Type", "application/json") + .withBody(Json.write(errorResponse)))); + + assertThrows( + ResponseException.class, + () -> client.removeTag(123L, new TagRemoveRequest("Some", URI.create("http://github.com")))); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/TestUtils.java b/bot/src/test/java/backend/academy/bot/command/TestUtils.java new file mode 100644 index 0000000..4f98713 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/TestUtils.java @@ -0,0 +1,22 @@ +package backend.academy.bot.command; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.pengrad.telegrambot.model.Chat; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; + +public interface TestUtils { + + default Update getMockUpdate(Long id, String text) { + Update update = mock(Update.class); + Chat chat = mock(Chat.class); + when(chat.id()).thenReturn(id); + Message message = mock(Message.class); + when(message.text()).thenReturn(text); + when(message.chat()).thenReturn(chat); + when(update.message()).thenReturn(message); + return update; + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/filter/FilterCommandTest.java b/bot/src/test/java/backend/academy/bot/command/filter/FilterCommandTest.java new file mode 100644 index 0000000..9d49587 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/filter/FilterCommandTest.java @@ -0,0 +1,112 @@ +package backend.academy.bot.command.filter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class FilterCommandTest implements TestUtils { + + @Mock + private ScrapperFilterClient scrapperFilterClient; + + @Mock + private ParserMessage parserMessage; + + private FilterCommand filterCommand; + + private static final Long USER_ID = 6758392L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + filterCommand = new FilterCommand(scrapperFilterClient, parserMessage); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/filter", filterCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Позволяет добавить фильтрацию на получение уведомлений", filterCommand.description()); + } + + private final String VALID_COMMAND = "/filter important"; + private final String INVALID_COMMAND = "/filter"; + + @Test + @DisplayName("Успешное добавление фильтра") + void handle_shouldSuccessfullyAddFilter() { + // Arrange + Update update = getMockUpdate(USER_ID, VALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /filter filterName"; + + when(parserMessage.parseMessageFilter(VALID_COMMAND, expectedErrorMsg)).thenReturn("important"); + + // Act + SendMessage result = filterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals( + "Фильтр успешно добавлен", result.getParameters().get("text")); + verify(scrapperFilterClient).createFilter(USER_ID, new FilterRequest("important")); + } + + @Test + @DisplayName("Обработка некорректного ввода") + void handle_shouldHandleInvalidInput() { + // Arrange + Update update = getMockUpdate(USER_ID, INVALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /filter filterName"; + when(parserMessage.parseMessageFilter(INVALID_COMMAND, expectedErrorMsg)) + .thenThrow(new InvalidInputFormatException("Ошибка формата")); + + // Act + SendMessage result = filterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals("Ошибка формата", result.getParameters().get("text")); + } + + @Test + @DisplayName("Обработка существующего фильтра") + void handle_shouldHandleExistingFilter() { + // Arrange + Update update = getMockUpdate(USER_ID, VALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /filter filterName"; + when(parserMessage.parseMessageFilter(VALID_COMMAND, expectedErrorMsg)).thenReturn("important"); + when(scrapperFilterClient.createFilter(anyLong(), any())).thenThrow(new ResponseException("Фильтр существует")); + + // Act + SendMessage result = filterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals( + "Ошибка: такой фильтр уже существует", result.getParameters().get("text")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/filter/FilterListCommandTest.java b/bot/src/test/java/backend/academy/bot/command/filter/FilterListCommandTest.java new file mode 100644 index 0000000..df1e03b --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/filter/FilterListCommandTest.java @@ -0,0 +1,105 @@ +package backend.academy.bot.command.filter; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.response.filter.FilterListResponse; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class FilterListCommandTest implements TestUtils { + + @Mock + private ScrapperFilterClient scrapperFilterClient; + + @Mock + private ParserMessage parserMessage; + + private FilterListCommand filterListCommand; + + private static final Long USER_ID = 6758392L; + + @BeforeEach + void setUp() { + filterListCommand = new FilterListCommand(scrapperFilterClient, parserMessage); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/filterlist", filterListCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Выводи все фильтры", filterListCommand.description()); + } + + @DisplayName("Успешное получение списка фильтров") + @Test + void handle_SuccessfulFilterList() throws ResponseException, InvalidInputFormatException { + // Arrange + Update update = getMockUpdate(USER_ID, "/filterlist"); + List filters = List.of(new FilterResponse(1L, "filter1"), new FilterResponse(2L, "filter2")); + FilterListResponse response = new FilterListResponse(filters); + + when(scrapperFilterClient.getFilterList(USER_ID)).thenReturn(response); + + // Act + SendMessage result = filterListCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + String expectedMessage = "Фильтры blackList:\n1) filter1\n2) filter2\n"; + Assertions.assertEquals(expectedMessage, result.getParameters().get("text")); + } + + @DisplayName("Обработка ошибки парсинга сообщения") + @Test + void handle_InvalidInputFormat() throws InvalidInputFormatException { + // Arrange + Update update = getMockUpdate(USER_ID, "/filterlist Invalid"); + doThrow(new InvalidInputFormatException("Неверный формат")) + .when(parserMessage) + .parseMessageFilterList(anyString()); + + // Act + SendMessage result = filterListCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertTrue(((String) result.getParameters().get("text")).startsWith("Ошибка: ")); + } + + @DisplayName("Обработка ошибки от бэкенда") + @Test + void handle_BackendError() throws ResponseException, InvalidInputFormatException { + // Arrange + Update update = getMockUpdate(USER_ID, "/filterlist"); + when(scrapperFilterClient.getFilterList(USER_ID)).thenThrow(new ResponseException("Ошибка сервера")); + + // Act + SendMessage result = filterListCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertTrue(((String) result.getParameters().get("text")).startsWith("Ошибка: ")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/filter/UnFilterCommandTest.java b/bot/src/test/java/backend/academy/bot/command/filter/UnFilterCommandTest.java new file mode 100644 index 0000000..bedfbf7 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/filter/UnFilterCommandTest.java @@ -0,0 +1,120 @@ +package backend.academy.bot.command.filter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.filter.FilterRequest; +import backend.academy.bot.api.dto.response.filter.FilterResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.filter.ScrapperFilterClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class UnFilterCommandTest implements TestUtils { + @Mock + private ScrapperFilterClient scrapperFilterClient; + + @Mock + private ParserMessage parserMessage; + + private UnFilterCommand unFilterCommand; + + private static final Long USER_ID = 6758392L; + private static final String VALID_COMMAND = "/unfilter important"; + private static final String INVALID_COMMAND = "/unfilter"; + + @BeforeEach + void setUp() { + unFilterCommand = new UnFilterCommand(scrapperFilterClient, parserMessage); + } + + @Test + @DisplayName("Проверка наименования команды") + void testCommandTrack() { + Assertions.assertEquals("/unfilter", unFilterCommand.command()); + } + + @Test + @DisplayName("Проверка описания") + void testCommandDescription() { + Assertions.assertEquals("Удаление фильтров", unFilterCommand.description()); + } + + @Test + @DisplayName("Успешное удаление фильтра") + void handle_shouldSuccessfullyRemoveFilter() { + // Arrange + Update update = getMockUpdate(USER_ID, VALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /unfilter filterName"; + + when(parserMessage.parseMessageFilter(VALID_COMMAND, expectedErrorMsg)).thenReturn("important"); + + FilterResponse mockResponse = new FilterResponse(3L, "important"); + when(scrapperFilterClient.deleteFilter(anyLong(), any(FilterRequest.class))) + .thenReturn(mockResponse); + + // Act + SendMessage result = unFilterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals( + "фильтр успешно удален: important", result.getParameters().get("text")); + verify(scrapperFilterClient).deleteFilter(USER_ID, new FilterRequest("important")); + } + + @Test + @DisplayName("Обработка некорректного ввода") + void handle_shouldHandleInvalidInput() { + // Arrange + Update update = getMockUpdate(USER_ID, INVALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /unfilter filterName"; + + when(parserMessage.parseMessageFilter(INVALID_COMMAND, expectedErrorMsg)) + .thenThrow(new InvalidInputFormatException(expectedErrorMsg)); + + // Act + SendMessage result = unFilterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals(expectedErrorMsg, result.getParameters().get("text")); + verify(scrapperFilterClient, never()).deleteFilter(anyLong(), any()); + } + + @Test + @DisplayName("Обработка ошибки при удалении фильтра") + void handle_shouldHandleFilterDeletionError() { + // Arrange + Update update = getMockUpdate(USER_ID, VALID_COMMAND); + String expectedErrorMsg = "Некорректный формат ввода. Ожидается: /unfilter filterName"; + + when(parserMessage.parseMessageFilter(VALID_COMMAND, expectedErrorMsg)).thenReturn("important"); + + when(scrapperFilterClient.deleteFilter(anyLong(), any(FilterRequest.class))) + .thenThrow(new ResponseException("Фильтр не найден")); + + // Act + SendMessage result = unFilterCommand.handle(update); + + // Assert + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals( + "Ошибка: Фильтр не найден", result.getParameters().get("text")); + verify(scrapperFilterClient).deleteFilter(USER_ID, new FilterRequest("important")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/helper/HelpCommandTest.java b/bot/src/test/java/backend/academy/bot/command/helper/HelpCommandTest.java new file mode 100644 index 0000000..72847e7 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/helper/HelpCommandTest.java @@ -0,0 +1,139 @@ +// package backend.academy.bot.command.helper; +// +// import static org.junit.jupiter.api.Assertions.assertEquals; +// import static org.mockito.Mockito.mock; +// import static org.mockito.Mockito.verify; +// import static org.mockito.Mockito.when; +// +// import backend.academy.bot.command.filter.FilterCommand; +// import backend.academy.bot.command.filter.FilterListCommand; +// import backend.academy.bot.command.filter.UnFilterCommand; +// import backend.academy.bot.command.link.ListCommand; +// import backend.academy.bot.command.link.TrackCommand; +// import backend.academy.bot.command.link.UntrackCommand; +// import backend.academy.bot.command.tag.TagCommand; +// import backend.academy.bot.command.tag.TagListCommand; +// import backend.academy.bot.command.tag.UnTagCommand; +// import backend.academy.bot.kafka.client.KafkaInvalidLinkProducer; +// import backend.academy.bot.message.ParserMessage; +// import backend.academy.bot.redis.RedisCacheService; +// import backend.academy.bot.state.UserState; +// import backend.academy.bot.state.UserStateManager; +// import com.pengrad.telegrambot.model.Chat; +// import com.pengrad.telegrambot.model.Message; +// import com.pengrad.telegrambot.model.Update; +// import com.pengrad.telegrambot.request.SendMessage; +// import java.util.List; +// 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.junit.jupiter.api.extension.ExtendWith; +// import org.mockito.Mock; +// import org.mockito.junit.jupiter.MockitoExtension; +// +// @ExtendWith(MockitoExtension.class) +// public class HelpCommandTest { +// +// @Mock +// private UserStateManager userStateManager; +// +// @Mock +// private ScrapperClient scrapperClient; +// +// @Mock +// private ParserMessage parserMessage; +// +// @Mock +// private RedisCacheService redisCacheService; +// +// @Mock +// private KafkaInvalidLinkProducer kafkaInvalidLinkProducer; +// +// private HelpCommand helpCommand; +// +// private static final Long USER_ID = 10231L; +// +// @BeforeEach +// void setUp() { +// StartCommand startCommand = new StartCommand(scrapperClient, userStateManager); +// +// TagCommand tagCommand = new TagCommand(scrapperClient, parserMessage); +// TagListCommand tagCommandList = new TagListCommand(scrapperClient, parserMessage); +// UnTagCommand unTagCommand = new UnTagCommand(scrapperClient, parserMessage, redisCacheService); +// +// ListCommand listCommand = new ListCommand(scrapperClient, userStateManager, redisCacheService); +// TrackCommand trackCommand = new TrackCommand( +// scrapperClient, parserMessage, userStateManager, redisCacheService, kafkaInvalidLinkProducer); +// UntrackCommand untrackCommand = +// new UntrackCommand(scrapperClient, parserMessage, userStateManager, redisCacheService); +// +// FilterCommand filterCommand = new FilterCommand(scrapperClient, parserMessage); +// FilterListCommand filterListCommand = new FilterListCommand(scrapperClient, parserMessage); +// UnFilterCommand unFilterCommand = new UnFilterCommand(scrapperClient, parserMessage); +// +// helpCommand = new HelpCommand( +// List.of( +// startCommand, +// tagCommand, +// tagCommandList, +// unTagCommand, +// listCommand, +// trackCommand, +// untrackCommand, +// filterCommand, +// filterListCommand, +// unFilterCommand), +// userStateManager); +// } +// +// @Test +// @DisplayName("Проверка команды") +// void shouldReturnCorrectCommand() { +// Assertions.assertEquals("/help", helpCommand.command()); +// } +// +// @Test +// @DisplayName("Проверка описания") +// void shouldReturnCorrectDescription() { +// Assertions.assertEquals("Выводит список всех доступных команд", helpCommand.description()); +// } +// +// @Test +// @DisplayName("Обработка команды /help") +// void handle_shouldReturnListOfCommands() { +// // Act +// Update update = getMockUpdate(USER_ID); +// SendMessage result = helpCommand.handle(update); +// +// // Assert +// String expectedMessage = +// """ +// /start -- Начинает работу бота +// /tag -- Позволяет выводить ссылки по тегам +// /taglist -- Выводит все теги пользователя +// /untag -- Удаление тега у ссылок +// /list -- Выводит список отслеживаемых ссылок +// /track -- Добавляет ссылку для отслеживания +// /untrack -- Удаляет ссылку для отслеживания +// /filter -- Позволяет добавить фильтрацию на получение уведомлений +// /filterlist -- Выводи все фильтры +// /unfilter -- Удаление фильтров +// """ +// .trim(); +// +// assertEquals(expectedMessage, result.getParameters().get("text")); +// assertEquals(USER_ID, result.getParameters().get("chat_id")); +// verify(userStateManager).setUserStatus(USER_ID, UserState.WAITING_COMMAND); +// } +// +// private Update getMockUpdate(Long id) { +// Update update = mock(Update.class); +// Chat chat = mock(Chat.class); +// when(chat.id()).thenReturn(id); +// Message message = mock(Message.class); +// when(message.chat()).thenReturn(chat); +// when(update.message()).thenReturn(message); +// return update; +// } +// } diff --git a/bot/src/test/java/backend/academy/bot/command/helper/StartCommandTest.java b/bot/src/test/java/backend/academy/bot/command/helper/StartCommandTest.java new file mode 100644 index 0000000..4d42d31 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/helper/StartCommandTest.java @@ -0,0 +1,74 @@ +package backend.academy.bot.command.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doThrow; + +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.chat.ScrapperTgChatClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +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.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class StartCommandTest implements TestUtils { + + @Mock + private ScrapperTgChatClient scrapperTgChatClient; + + @Mock + private UserStateManager userStateManager; + + private StartCommand startCommand; + + private static final Long USER_ID = 10231L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + startCommand = new StartCommand(scrapperTgChatClient, userStateManager); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/start", startCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Начинает работу бота", startCommand.description()); + } + + @Test + @DisplayName("Проверка при вводе первый раз старт") + void startCommand() { + Update update = getMockUpdate(USER_ID, "text"); + SendMessage sendMessage = startCommand.handle(update); + assertEquals( + "Привет! Используй /help чтобы увидеть все команды", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Проверка при вводе второй раз старт") + void startCommandTwoTime() { + // Arrange + Update update = getMockUpdate(USER_ID, "/start"); + doThrow(new ResponseException("Ты уже зарегистрировался :)")) + .when(scrapperTgChatClient) + .registerChat(USER_ID); + + // Act + SendMessage result = startCommand.handle(update); + + // Assert + assertEquals("Ты уже зарегистрировался :)", result.getParameters().get("text")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/link/ListCommandTest.java b/bot/src/test/java/backend/academy/bot/command/link/ListCommandTest.java new file mode 100644 index 0000000..fcb8495 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/link/ListCommandTest.java @@ -0,0 +1,117 @@ +package backend.academy.bot.command.link; + +import static org.mockito.Mockito.*; + +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Chat; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import java.util.List; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class ListCommandTest { + + private ListCommand listCommand; + + @Mock + private ScrapperLinkClient scrapperLinkClient; + + @Mock + private UserStateManager userStateManager; + + @Mock + private RedisCacheService redisCacheService; + + private static final Long USER_ID = 6758392L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + listCommand = new ListCommand(scrapperLinkClient, userStateManager, redisCacheService); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/list", listCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Выводит список отслеживаемых ссылок", listCommand.description()); + } + + @Test + @DisplayName("Тест на отслеживания ссылок, которых нет") + public void handleEmptyTrackList() { + Update update = getMockUpdate(USER_ID); + when(scrapperLinkClient.getListLink(USER_ID)).thenReturn(new ListLinksResponse(List.of(), 0)); + SendMessage sendMessage = listCommand.handle(update); + Assertions.assertEquals( + "Никакие ссылки не отслеживаются", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Тест на проверку, отслеживаемых ссылок") + public void handleNotEmptyTrackList() { + Update update = getMockUpdate(USER_ID); + + List links = List.of( + new LinkResponse(5L, URI.create("http://github.com"), List.of("tag1"), List.of("filter1")), + new LinkResponse(6L, URI.create("http://stackoverflow.com"), List.of("tag2"), List.of("filter2"))); + ListLinksResponse response = new ListLinksResponse(links, links.size()); + + when(scrapperLinkClient.getListLink(USER_ID)).thenReturn(response); + + // Act + SendMessage sendMessage = listCommand.handle(update); + + // Assert + String expectedMessage = "Отслеживаемые ссылки:\n" + "1)\n" + + "URL:http://github.com\n" + + "tags:[tag1]\n" + + "filters:[filter1]\n" + + "2)\n" + + "URL:http://stackoverflow.com\n" + + "tags:[tag2]\n" + + "filters:[filter2]\n"; + Assertions.assertEquals(expectedMessage, sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Тест на проверку, отслеживаемых ссылок, с ошибкой при получении ссылок") + public void handleResponseException() { + Update update = getMockUpdate(USER_ID); + + when(scrapperLinkClient.getListLink(USER_ID)).thenThrow(new ResponseException("Ошибка")); + + SendMessage sendMessage = listCommand.handle(update); + Assertions.assertEquals("Ошибка", sendMessage.getParameters().get("text")); + } + + private Update getMockUpdate(Long id) { + Update update = mock(Update.class); + Chat chat = mock(Chat.class); + when(chat.id()).thenReturn(id); + Message message = mock(Message.class); + when(message.chat()).thenReturn(chat); + when(update.message()).thenReturn(message); + return update; + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/link/TrackCommandTest.java b/bot/src/test/java/backend/academy/bot/command/link/TrackCommandTest.java new file mode 100644 index 0000000..19a837b --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/link/TrackCommandTest.java @@ -0,0 +1,179 @@ +package backend.academy.bot.command.link; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.AddLinkRequest; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.kafka.client.KafkaInvalidLinkProducer; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class TrackCommandTest implements TestUtils { + + private TrackCommand trackCommand; + + @Mock + private ScrapperLinkClient scrapperLinkClient; + + @Mock + private UserStateManager userStateManager; + + @Mock + private ParserMessage parserMessage; + + @Mock + private RedisCacheService redisCacheService; + + @Mock + private KafkaInvalidLinkProducer kafkaInvalidLinkProducer; + + private static final Long USER_ID = 6758392L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + trackCommand = new TrackCommand( + scrapperLinkClient, parserMessage, userStateManager, redisCacheService, kafkaInvalidLinkProducer); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/track", trackCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Добавляет ссылку для отслеживания", trackCommand.description()); + } + + @Test + @DisplayName("Ввод верной ссылки") + void handleCorrectUrlShouldReturnSuccessResponse() { + // Arrange + String commandMessage = "/track https://github.com/"; + Update update = getMockUpdate(USER_ID, commandMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_URL); + + // Act + SendMessage sendMessage = trackCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Введите теги через пробел для ссылки", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Ввод неправильной ссылки") + void handleIncorrectUrl() { + // Arrange + String commandMessage = "/track http://giф"; + Update update = getMockUpdate(USER_ID, commandMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_URL); + + doThrow(new InvalidInputFormatException("Use a valid URL as a parameter in the form like '/track '")) + .when(parserMessage) + .parseUrl(commandMessage, UserState.WAITING_URL); + + // Act + SendMessage sendMessage = trackCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Use a valid URL as a parameter in the form like '/track '", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Проверка введение фильтров") + void handleTagsInput() { + // Arrange + String tagsMessage = "tag1 tag2"; + Update update = getMockUpdate(USER_ID, tagsMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_TAGS); + + SendMessage sendMessage = trackCommand.handle(update); + + Assertions.assertEquals( + "Введите фильтры через пробел для ссылки", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Повторное добавление ссылки") + void handleDuplicateLink() { + String filtersMessage = "filter1 filter2"; + Update update = getMockUpdate(USER_ID, filtersMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_FILTERS); + + when(scrapperLinkClient.trackLink(eq(USER_ID), any(AddLinkRequest.class))) + .thenThrow(new ResponseException("Link already exists")); + + SendMessage sendMessage = trackCommand.handle(update); + + Assertions.assertEquals( + "Такая ссылка уже добавлена, добавьте новую ссылку используя /track", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Проверка пустых тегов") + void handleInvalidTagsInput() { + String invalidTagsMessage = ""; + Update update = getMockUpdate(USER_ID, invalidTagsMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_TAGS); + + doThrow(new InvalidInputFormatException("Теги не могут быть пустыми")) + .when(parserMessage) + .getAdditionalAttribute(invalidTagsMessage); + + SendMessage sendMessage = trackCommand.handle(update); + + Assertions.assertEquals( + "Теги не могут быть пустыми", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Проверка пустых фильтров") + void handleInvalidFiltersInput() { + String invalidFiltersMessage = ""; + Update update = getMockUpdate(USER_ID, invalidFiltersMessage); + + when(userStateManager.getUserState(USER_ID)).thenReturn(UserState.WAITING_FILTERS); + + doThrow(new InvalidInputFormatException("Фильтры не могут быть пустыми")) + .when(parserMessage) + .getAdditionalAttribute(invalidFiltersMessage); + + SendMessage sendMessage = trackCommand.handle(update); + + Assertions.assertEquals( + "Фильтры не могут быть пустыми", sendMessage.getParameters().get("text")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/link/UntrackCommandTest.java b/bot/src/test/java/backend/academy/bot/command/link/UntrackCommandTest.java new file mode 100644 index 0000000..50947bc --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/link/UntrackCommandTest.java @@ -0,0 +1,144 @@ +package backend.academy.bot.command.link; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.RemoveLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.link.ScrapperLinkClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import java.util.List; +import lombok.SneakyThrows; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UntrackCommandTest implements TestUtils { + + @Mock + private ScrapperLinkClient scrapperLinkClient; + + @Mock + private ParserMessage parserMessage; + + @Mock + private UserStateManager userStateManager; + + @Mock + private RedisCacheService redisCacheService; + + private UntrackCommand untrackCommand; + + private static final Long USER_ID = 6758392L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + untrackCommand = new UntrackCommand(scrapperLinkClient, parserMessage, userStateManager, redisCacheService); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/untrack", untrackCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Удаляет ссылку для отслеживания", untrackCommand.description()); + } + + @Test + @DisplayName("Успешное удаление ссылки") + @SneakyThrows + void handleCorrectUrlShouldReturnSuccessResponse() { + // Arrange + String commandMessage = "/untrack https://github.com/Delphington"; + Update update = getMockUpdate(USER_ID, commandMessage); + + URI uri = URI.create("https://github.com/Delphington"); + LinkResponse linkResponse = new LinkResponse(1L, uri, List.of(), List.of()); + RemoveLinkRequest removeLinkRequest = new RemoveLinkRequest(uri); + + when(parserMessage.parseUrl(commandMessage)).thenReturn(uri); + when(scrapperLinkClient.untrackLink(USER_ID, removeLinkRequest)).thenReturn(linkResponse); + + // Act + SendMessage sendMessage = untrackCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Ссылка удаленна https://github.com/Delphington", + sendMessage.getParameters().get("text")); + + // Verify + verify(redisCacheService).invalidateCache(USER_ID); + verify(userStateManager).setUserStatus(USER_ID, UserState.WAITING_COMMAND); + } + + @Test + @DisplayName("Не корректный ввод URL для удаления") + @SneakyThrows + void handleIncorrectUrl() { + // Arrange + String commandMessage = "/untrack"; + Update update = getMockUpdate(USER_ID, commandMessage); + + when(parserMessage.parseUrl(commandMessage)) + .thenThrow( + new InvalidInputFormatException("Некорректный URL. Используйте URL в формате /untrack ")); + + // Act + SendMessage sendMessage = untrackCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Некорректный URL. Используйте URL в формате /untrack ", + sendMessage.getParameters().get("text")); + + verify(redisCacheService).invalidateCache(USER_ID); + verify(userStateManager).setUserStatus(USER_ID, UserState.WAITING_COMMAND); + } + + @Test + @DisplayName("Удаление ссылки, которой не существует") + @SneakyThrows + void handleLinkNotFound() { + // Arrange + String commandMessage = "/untrack https://github.com/Delphington"; + Update update = getMockUpdate(USER_ID, commandMessage); + + URI uri = URI.create("https://github.com/Delphington"); + + when(parserMessage.parseUrl(commandMessage)).thenReturn(uri); + when(scrapperLinkClient.untrackLink(eq(USER_ID), any(RemoveLinkRequest.class))) + .thenThrow(new ResponseException("Ссылка не найдена")); + + // Act + SendMessage sendMessage = untrackCommand.handle(update); + + // Assert + Assertions.assertEquals("Ссылка не найдена", sendMessage.getParameters().get("text")); + + verify(redisCacheService).invalidateCache(USER_ID); + verify(userStateManager).setUserStatus(USER_ID, UserState.WAITING_COMMAND); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/tag/TagCommandTest.java b/bot/src/test/java/backend/academy/bot/command/tag/TagCommandTest.java new file mode 100644 index 0000000..0eafcc8 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/tag/TagCommandTest.java @@ -0,0 +1,117 @@ +package backend.academy.bot.command.tag; + +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.tag.TagLinkRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import java.util.List; +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.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class TagCommandTest implements TestUtils { + + @Mock + private ScrapperTagClient scrapperTagClient; + + @Mock + private ParserMessage parserMessage; + + private TagCommand tagCommand; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + tagCommand = new TagCommand(scrapperTagClient, parserMessage); + } + + @DisplayName("Проверка наименования команды") + @Test + void testCommandTrack() { + Assertions.assertEquals("/tag", tagCommand.command()); + } + + @DisplayName("Проверка описания") + @Test + void testCommandDescription() { + Assertions.assertEquals("Позволяет выводить ссылки по тегам", tagCommand.description()); + } + + private static final Long USER_ID = 14141L; + + @Test + @DisplayName("Корректный ввод тега и получение списка ссылок") + void handleValidTagInput() { + // Arrange + String tagMessage = "/tag tag1"; + Update update = getMockUpdate(USER_ID, tagMessage); + + String tag = "tag1"; + List links = List.of( + new LinkResponse(1L, URI.create("https://github.com/"), List.of("tag1"), List.of()), + new LinkResponse(2L, URI.create("https://example.com/"), List.of("tag1"), List.of())); + ListLinksResponse listLinksResponse = new ListLinksResponse(links, links.size()); + + when(parserMessage.parseMessageTag(tagMessage.trim())).thenReturn(tag); + when(scrapperTagClient.getListLinksByTag(USER_ID, new TagLinkRequest(tag))) + .thenReturn(listLinksResponse); + + // Act + SendMessage sendMessage = tagCommand.handle(update); + + // Assert + String expectedMessage = + "С тегом: tag1\nОтслеживаемые ссылки:\n1) URL:https://github.com/\n2) URL:https://example.com/\n"; + Assertions.assertEquals(expectedMessage, sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Некорректный ввод тега") + void handleInvalidTagInput() { + // Arrange + String invalidTagMessage = "/tag"; + Update update = getMockUpdate(USER_ID, invalidTagMessage); + + when(parserMessage.parseMessageTag(invalidTagMessage)) + .thenThrow(new InvalidInputFormatException("Тег не может быть пустым")); + // Act + SendMessage sendMessage = tagCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Тег не может быть пустым", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Ошибка при получении списка ссылок из базы данных") + void handleDatabaseError() { + // Arrange + String tagMessage = "/tag tag1"; + Update update = getMockUpdate(USER_ID, tagMessage); + + String tag = "tag1"; + + when(parserMessage.parseMessageTag(tagMessage.trim())).thenReturn(tag); + when(scrapperTagClient.getListLinksByTag(USER_ID, new TagLinkRequest(tag))) + .thenThrow(new ResponseException("Ошибка базы данных")); + + // Act + SendMessage sendMessage = tagCommand.handle(update); + + // Assert + String expectedMessage = "С тегом: tag1\nОшибка! попробуй еще раз"; + Assertions.assertEquals(expectedMessage, sendMessage.getParameters().get("text")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/tag/TagListCommandTest.java b/bot/src/test/java/backend/academy/bot/command/tag/TagListCommandTest.java new file mode 100644 index 0000000..f45b974 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/tag/TagListCommandTest.java @@ -0,0 +1,141 @@ +package backend.academy.bot.command.tag; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.response.TagListResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +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.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class TagListCommandTest implements TestUtils { + + private TagListCommand tagListCommand; + + @Mock + private ScrapperTagClient scrapperTagClient; + + @Mock + private ParserMessage parserMessage; + + private static final Long USER_ID = 245151L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + tagListCommand = new TagListCommand(scrapperTagClient, parserMessage); + } + + @Test + @DisplayName("Проверка команды") + void shouldReturnCorrectCommand() { + Assertions.assertEquals("/taglist", tagListCommand.command()); + } + + @Test + @DisplayName("Проверка описания") + void shouldReturnCorrectDescription() { + Assertions.assertEquals("Выводит все теги пользователя", tagListCommand.description()); + } + + @Test + @DisplayName("Некорректный ввод команды с лишними аргументами") + void handleInvalidTagListInputWithExtraArguments() { + // Arrange + String invalidTagListMessage = "/taglist extraArgument"; + Update update = getMockUpdate(USER_ID, invalidTagListMessage); + + // Метод parseMessageTagList выбрасывает исключение при наличии лишних аргументов + doThrow(new InvalidInputFormatException("Некорректный формат строки. Ожидается: /taglist")) + .when(parserMessage) + .parseMessageTagList(invalidTagListMessage.trim()); + + // Act + SendMessage sendMessage = tagListCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Некорректный формат строки. Ожидается: /taglist", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Некорректный ввод команды с пустым сообщением") + void handleInvalidTagListInputWithEmptyMessage() { + // Arrange + String emptyMessage = ""; + Update update = getMockUpdate(USER_ID, emptyMessage); + + doThrow(new InvalidInputFormatException("Некорректный формат строки. Ожидается: /taglist")) + .when(parserMessage) + .parseMessageTagList(emptyMessage.trim()); + + // Act + SendMessage sendMessage = tagListCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Некорректный формат строки. Ожидается: /taglist", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Ошибка при получении списка тегов из базы данных") + void handleDatabaseError() { + // Arrange + Long chatId = 5L; + String tagListMessage = "/taglist"; + Update update = getMockUpdate(chatId, tagListMessage); + + // Метод parseMessageTagList не выбрасывает исключение для корректного ввода + when(scrapperTagClient.getAllListLinksByTag(chatId)).thenThrow(new ResponseException("Ошибка базы данных")); + + // Act + SendMessage sendMessage = tagListCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Ошибка попробуй еще раз", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Успешное получение списка тегов") + void handle_shouldReturnTagListSuccessfully() throws Exception { + // Arrange + String commandText = "/taglist"; + Update update = getMockUpdate(USER_ID, commandText); + + TagListResponse mockResponse = new TagListResponse(List.of("tag1", "tag2", "tag3")); + + when(scrapperTagClient.getAllListLinksByTag(anyLong())).thenReturn(mockResponse); + + // Act + SendMessage result = tagListCommand.handle(update); + + // Assert + String expectedMessage = + """ + Ваши теги: + 1) tag1 + 2) tag2 + 3) tag3 + """ + .trim(); + + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals( + expectedMessage, result.getParameters().get("text").toString().trim()); + } +} diff --git a/bot/src/test/java/backend/academy/bot/command/tag/UnTagCommandTest.java b/bot/src/test/java/backend/academy/bot/command/tag/UnTagCommandTest.java new file mode 100644 index 0000000..ff114fb --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/command/tag/UnTagCommandTest.java @@ -0,0 +1,155 @@ +package backend.academy.bot.command.tag; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.exception.ResponseException; +import backend.academy.bot.client.tag.ScrapperTagClient; +import backend.academy.bot.command.TestUtils; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.message.ParserMessage; +import backend.academy.bot.redis.RedisCacheService; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.net.URI; +import java.util.List; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class UnTagCommandTest implements TestUtils { + + private UnTagCommand unTagCommand; + + @Mock + private ScrapperTagClient scrapperTagClient; + + @Mock + private ParserMessage parserMessage; + + @Mock + private RedisCacheService redisCacheService; + + private static final Long USER_ID = 245151L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + unTagCommand = new UnTagCommand(scrapperTagClient, parserMessage, redisCacheService); + } + + @Test + @DisplayName("Проверка команды") + void shouldReturnCorrectCommand() { + Assertions.assertEquals("/untag", unTagCommand.command()); + } + + @Test + @DisplayName("Проверка описания") + void shouldReturnCorrectDescription() { + Assertions.assertEquals("Удаление тега у ссылок", unTagCommand.description()); + } + + @Test + @DisplayName("Некорректный формат команды") + void handleInvalidUnTagInput() { + // Arrange + String invalidUnTagMessage = "/untag"; + Update update = getMockUpdate(USER_ID, invalidUnTagMessage); + + doThrow(new InvalidInputFormatException("Некорректный формат команды. Ожидается: /untag <тег> <ссылка>")) + .when(parserMessage) + .parseMessageUnTag(invalidUnTagMessage); + + // Act + SendMessage sendMessage = unTagCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Некорректный формат команды. Ожидается: /untag <тег> <ссылка>", + sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Ошибка при удалении тега") + void handleUnTagError() { + // Arrange + String unTagMessage = "/untag tag1 https://example.com"; + Update update = getMockUpdate(USER_ID, unTagMessage); + + TagRemoveRequest tagRemoveRequest = new TagRemoveRequest("tag1", URI.create("https://example.com")); + + when(parserMessage.parseMessageUnTag(unTagMessage)).thenReturn(tagRemoveRequest); + when(scrapperTagClient.removeTag(USER_ID, tagRemoveRequest)) + .thenThrow(new ResponseException("Ошибка при удалении тега")); + + // Act + SendMessage sendMessage = unTagCommand.handle(update); + + // Assert + Assertions.assertEquals( + "Ошибка: Ошибка при удалении тега", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Некорректный URL в команде") + void handleInvalidUrlInUnTagCommand() { + // Arrange + String invalidUrlMessage = "/untag tag1 invalidUrl"; + Update update = getMockUpdate(USER_ID, invalidUrlMessage); + + doThrow(new InvalidInputFormatException("Некорректный URL")) + .when(parserMessage) + .parseMessageUnTag(invalidUrlMessage); + + // Act + SendMessage sendMessage = unTagCommand.handle(update); + + // Assert + Assertions.assertEquals("Некорректный URL", sendMessage.getParameters().get("text")); + } + + @Test + @DisplayName("Успешное удаление тега") + void handle_shouldSuccessfullyRemoveTag() { + // Arrange + String COMMAND_TEXT = "/untag test_tag https://github.com"; + Update update = getMockUpdate(USER_ID, COMMAND_TEXT); // Используем полный текст команды + + TagRemoveRequest tagRemoveRequest = new TagRemoveRequest("test_tag", URI.create("https://github.com")); + when(parserMessage.parseMessageUnTag(COMMAND_TEXT)).thenReturn(tagRemoveRequest); + + LinkResponse mockResponse = + new LinkResponse(1L, URI.create("https://github.com"), List.of("remaining_tag"), List.of("filter1")); + when(scrapperTagClient.removeTag(anyLong(), any(TagRemoveRequest.class))) + .thenReturn(mockResponse); + + // Act + SendMessage result = unTagCommand.handle(update); + + // Assert + String expectedMessage = + """ + Теги обновлены: + Ссылка: https://github.com + Теги: [remaining_tag] + Фильтры: [filter1]"""; + + Assertions.assertEquals(USER_ID, result.getParameters().get("chat_id")); + Assertions.assertEquals(expectedMessage, result.getParameters().get("text")); + + verify(redisCacheService).invalidateCache(USER_ID); + verify(scrapperTagClient).removeTag(USER_ID, tagRemoveRequest); + } +} diff --git a/bot/src/test/java/backend/academy/bot/executor/RequestExecutorTest.java b/bot/src/test/java/backend/academy/bot/executor/RequestExecutorTest.java new file mode 100644 index 0000000..17ed6d3 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/executor/RequestExecutorTest.java @@ -0,0 +1,29 @@ +package backend.academy.bot.executor; + +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.request.SendMessage; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class RequestExecutorTest { + + @Test + @DisplayName("RequestExecutor execute должен выкинуть исключение если telegramBot не задан") + public void executeShouldThrowIllegalStateExceptionWhenTelegramBotNotSet() { + TelegramBot telegramBot = null; + RequestExecutor executor = new RequestExecutor(telegramBot); + Assertions.assertThatThrownBy(() -> executor.execute(new SendMessage(1, "Testing"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("ТRequestExecutor должен выполнить запрос если telegramBot задан") + public void executeShouldExecuteWhenTelegramBotSet() { + TelegramBot mockTelegramBot = Mockito.mock(TelegramBot.class); + RequestExecutor executor = new RequestExecutor(mockTelegramBot); + executor.execute(new SendMessage(1, "Test message")); + Mockito.verify(mockTelegramBot, Mockito.times(1)).execute(Mockito.any(SendMessage.class)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/KafkaTestContainer.java b/bot/src/test/java/backend/academy/bot/integration/KafkaTestContainer.java new file mode 100644 index 0000000..7f49a1c --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/KafkaTestContainer.java @@ -0,0 +1,55 @@ +package backend.academy.bot.integration; + +import backend.academy.bot.api.dto.kafka.BadLink; +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration +public class KafkaTestContainer { + + @Container + public static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0")); + + static { + kafka.start(); + } + + @DynamicPropertySource + static void kafkaProperties(DynamicPropertyRegistry registry) { + registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); + } + + public static KafkaTemplate createKafkaTemplate() { + Map configProps = new HashMap<>(); + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers()); + configProps.put( + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + org.apache.kafka.common.serialization.StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + ProducerFactory producerFactory = new DefaultKafkaProducerFactory<>(configProps); + return new KafkaTemplate<>(producerFactory); + } + + public static KafkaTemplate createKafkaTemplateBad() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers()); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(config)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/RedisTestContainer.java b/bot/src/test/java/backend/academy/bot/integration/RedisTestContainer.java new file mode 100644 index 0000000..7fc0c20 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/RedisTestContainer.java @@ -0,0 +1,74 @@ +package backend.academy.bot.integration; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.util.List; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class RedisTestContainer { + private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.4.2"); + private static final int REDIS_PORT = 6379; + private static GenericContainer redisContainer; + + public static void startContainer() { + if (redisContainer == null) { + redisContainer = new GenericContainer<>(REDIS_IMAGE).withExposedPorts(REDIS_PORT); + redisContainer.start(); + } + } + + public static void stopContainer() { + if (redisContainer != null && redisContainer.isRunning()) { + redisContainer.stop(); + } + } + + public static RedisTemplate createRedisTemplate(Class valueType) { + if (redisContainer == null || !redisContainer.isRunning()) { + throw new IllegalStateException("Redis container is not running"); + } + + RedisStandaloneConfiguration config = + new RedisStandaloneConfiguration(redisContainer.getHost(), redisContainer.getMappedPort(REDIS_PORT)); + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(config); + connectionFactory.afterPropertiesSet(); + + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } + + public static RedisTemplate> createRedisTemplateList() { + if (redisContainer == null || !redisContainer.isRunning()) { + throw new IllegalStateException("Redis container is not running"); + } + + RedisStandaloneConfiguration config = + new RedisStandaloneConfiguration(redisContainer.getHost(), redisContainer.getMappedPort(REDIS_PORT)); + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(config); + connectionFactory.afterPropertiesSet(); + + RedisTemplate> redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } + + public static void flushAll(RedisTemplate redisTemplate) { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaInvalidLinkProducerTest.java b/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaInvalidLinkProducerTest.java new file mode 100644 index 0000000..40c7834 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaInvalidLinkProducerTest.java @@ -0,0 +1,80 @@ +package backend.academy.bot.integration.kafka; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +import backend.academy.bot.api.dto.kafka.BadLink; +import backend.academy.bot.integration.KafkaTestContainer; +import backend.academy.bot.kafka.client.KafkaInvalidLinkProducer; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.util.ReflectionTestUtils; + +@Slf4j +@DirtiesContext +public class KafkaInvalidLinkProducerTest { + + private KafkaTemplate kafkaTemplate; + + @InjectMocks + private KafkaInvalidLinkProducer producer; + + private static final String TOPIC = "dead-letter-queue"; + + @BeforeEach + void setUp() { + kafkaTemplate = KafkaTestContainer.createKafkaTemplateBad(); + producer = new KafkaInvalidLinkProducer(kafkaTemplate, TOPIC); + + // Устанавливаем значение для final-поля через рефлексию + ReflectionTestUtils.setField(producer, "topic", TOPIC); + } + + @Test + @DisplayName("Тестирование отправки невалидной ссылки в DLQ") + public void shouldSendInvalidLinkToDlq() { + // Arrange + BadLink badLink = new BadLink(404L, "http://invalid.url"); + + // Создаем consumer для проверки сообщений в DLQ + Map consumerProps = new HashMap<>(); + consumerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KafkaTestContainer.kafka.getBootstrapServers()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-dlq-consumer"); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class.getName()); + consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + + KafkaConsumer dlqConsumer = new KafkaConsumer<>(consumerProps); + dlqConsumer.subscribe(Collections.singletonList(TOPIC)); + + // Act + producer.sendInvalidLink(badLink); + + // Assert + await().pollInterval(Duration.ofMillis(100)) + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = dlqConsumer.poll(Duration.ofMillis(100)); + assertThat(records.count()).isEqualTo(1); + assertThat(records.iterator().next().value()).isEqualTo(badLink); + }); + + dlqConsumer.close(); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaLinkUpdateListenerTest.java b/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaLinkUpdateListenerTest.java new file mode 100644 index 0000000..05fa06c --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/kafka/KafkaLinkUpdateListenerTest.java @@ -0,0 +1,75 @@ +package backend.academy.bot.integration.kafka; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.integration.KafkaTestContainer; +import backend.academy.bot.kafka.client.KafkaLinkUpdateListener; +import backend.academy.bot.notification.MessageUpdateSender; +import backend.academy.bot.notification.NotificationProperties; +import backend.academy.bot.notification.NotificationService; +import backend.academy.bot.redis.RedisMessageService; +import java.net.URI; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.annotation.DirtiesContext; + +@Slf4j +@DirtiesContext +public class KafkaLinkUpdateListenerTest { + + private KafkaTemplate kafkaTemplate; + + private KafkaLinkUpdateListener kafkaLinkUpdateListener; + + @Mock + private NotificationProperties properties; + + @Mock + private MessageUpdateSender messageUpdateSender; + + @Mock + private RedisMessageService redisMessageService; + + @Mock + private NotificationService notificationService; + + private static final String TOPIC = "updated-topic"; + + @BeforeEach + void setUp() { + // Инициализация моков + MockitoAnnotations.openMocks(this); + + // Настройка моков для messageUpdateSender + doNothing().when(messageUpdateSender).sendMessage(Mockito.any(LinkUpdate.class)); + doNothing().when(notificationService).sendMessage(Mockito.any(LinkUpdate.class)); + + kafkaTemplate = KafkaTestContainer.createKafkaTemplate(); + kafkaLinkUpdateListener = new KafkaLinkUpdateListener(notificationService); + } + + @Test + @DisplayName("Тестирование KafkaUpdatesListener#listenUpdate с корректными данными") + public void listenUpdateShouldCatchUpdate() { + var linkUpdate = new LinkUpdate(1L, URI.create("http://test.com"), "test", List.of(1L)); + + // Отправляем сообщение в Kafka + kafkaTemplate.send(TOPIC, linkUpdate); + + // Симулируем вызов метода updateConsumer, как если бы он был вызван KafkaListener + kafkaLinkUpdateListener.updateConsumer(linkUpdate, TOPIC); + + // Проверяем, что метод sendMessage был вызван + verify(notificationService, times(1)).sendMessage(linkUpdate); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/redis/RedisCacheServiceIntegrationTest.java b/bot/src/test/java/backend/academy/bot/integration/redis/RedisCacheServiceIntegrationTest.java new file mode 100644 index 0000000..ce93b96 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/redis/RedisCacheServiceIntegrationTest.java @@ -0,0 +1,94 @@ +package backend.academy.bot.integration.redis; + +import static org.junit.jupiter.api.Assertions.*; + +import backend.academy.bot.api.dto.response.LinkResponse; +import backend.academy.bot.api.dto.response.ListLinksResponse; +import backend.academy.bot.integration.RedisTestContainer; +import backend.academy.bot.redis.RedisCacheService; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DisplayName("Тесты RedisCacheService с Testcontainers") +public class RedisCacheServiceIntegrationTest { + + private RedisTemplate redisTemplate; + private RedisCacheService redisCacheService; + + @BeforeAll + static void beforeAll() { + RedisTestContainer.startContainer(); + } + + @BeforeEach + void setUp() { + redisTemplate = RedisTestContainer.createRedisTemplate(Object.class); + redisCacheService = new RedisCacheService(redisTemplate); + RedisTestContainer.flushAll(redisTemplate); + } + + @Test + @DisplayName("Сохранение и получение данных из кеша") + void cacheAndGetLinks_ShouldWorkCorrectly() { + // Arrange + Long chatId = 12345L; + ListLinksResponse expectedResponse = new ListLinksResponse( + List.of( + new LinkResponse( + 1L, URI.create("https://github.com"), Collections.emptyList(), Collections.emptyList()), + new LinkResponse( + 2L, + URI.create("https://stackoverflow.com"), + Collections.emptyList(), + Collections.emptyList())), + 2); + + // Act + redisCacheService.cacheLinks(chatId, expectedResponse); + ListLinksResponse actualResponse = redisCacheService.getCachedLinks(chatId); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse.links().size(), actualResponse.links().size()); + assertEquals( + expectedResponse.links().get(0).url(), + actualResponse.links().get(0).url()); + } + + @Test + @DisplayName("Получение данных при отсутствии в кеше") + void getCachedLinks_WhenNotCached_ShouldReturnNull() { + // Arrange + Long chatId = 54321L; + + // Act + ListLinksResponse response = redisCacheService.getCachedLinks(chatId); + + // Assert + assertNull(response); + } + + @Test + @DisplayName("Инвалидация кеша") + void invalidateCache_ShouldRemoveData() { + // Arrange + Long chatId = 11111L; + ListLinksResponse response = new ListLinksResponse( + List.of(new LinkResponse( + 1L, URI.create("https://example.com"), Collections.emptyList(), Collections.emptyList())), + 1); + redisCacheService.cacheLinks(chatId, response); + + // Act + redisCacheService.invalidateCache(chatId); + ListLinksResponse afterInvalidation = redisCacheService.getCachedLinks(chatId); + + // Assert + assertNull(afterInvalidation); + } +} diff --git a/bot/src/test/java/backend/academy/bot/integration/redis/RedisMessageServiceIntegrationTest.java b/bot/src/test/java/backend/academy/bot/integration/redis/RedisMessageServiceIntegrationTest.java new file mode 100644 index 0000000..db97648 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/integration/redis/RedisMessageServiceIntegrationTest.java @@ -0,0 +1,96 @@ +package backend.academy.bot.integration.redis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.integration.RedisTestContainer; +import backend.academy.bot.redis.RedisMessageService; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.*; +import org.springframework.data.redis.core.RedisTemplate; + +class RedisMessageServiceIntegrationTest { + + private RedisTemplate> redisTemplate; + private RedisMessageService redisMessageService; + + @BeforeAll + static void beforeAll() { + RedisTestContainer.startContainer(); + } + + @BeforeEach + void setUp() { + redisTemplate = RedisTestContainer.createRedisTemplateList(); + redisMessageService = new RedisMessageService(redisTemplate); + RedisTestContainer.flushAll(redisTemplate); + } + + @Test + @DisplayName("Добавление и получение LinkUpdate из кеша") + void addAndGetCachedLinks_ShouldWorkCorrectly() { + // Arrange + LinkUpdate linkUpdate1 = + new LinkUpdate(1L, URI.create("https://github.com"), "Update 1", Collections.emptyList()); + LinkUpdate linkUpdate2 = + new LinkUpdate(2L, URI.create("https://stackoverflow.com"), "Update 2", Collections.emptyList()); + + // Act + redisMessageService.addCacheLinks(linkUpdate1); + redisMessageService.addCacheLinks(linkUpdate2); + List result = redisMessageService.getCachedLinks(); + + // Assert + assertNotNull(result); + assertEquals(linkUpdate1.url(), result.get(0).url()); + assertEquals(linkUpdate2.url(), result.get(1).url()); + } + + @Test + @DisplayName("Получение пустого списка при отсутствии данных в кеше") + void getCachedLinks_WhenEmpty_ShouldReturnNull() { + // Act + List result = redisMessageService.getCachedLinks(); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("Инвалидация кеша") + void invalidateCache_ShouldRemoveData() { + // Arrange + LinkUpdate linkUpdate = new LinkUpdate(1L, URI.create("https://example.com"), "Test", Collections.emptyList()); + redisMessageService.addCacheLinks(linkUpdate); + + // Act + redisMessageService.invalidateCache(); + List result = redisMessageService.getCachedLinks(); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("Добавление нескольких LinkUpdate в одной транзакции") + void addCacheLinks_ShouldHandleMultipleAdds() { + // Arrange + LinkUpdate linkUpdate1 = + new LinkUpdate(1L, URI.create("https://github.com"), "Update 1", Collections.emptyList()); + LinkUpdate linkUpdate2 = + new LinkUpdate(2L, URI.create("https://stackoverflow.com"), "Update 2", Collections.emptyList()); + + // Act + redisMessageService.addCacheLinks(linkUpdate1); + redisMessageService.addCacheLinks(linkUpdate2); + List result = redisMessageService.getCachedLinks(); + + // Assert + assertEquals(linkUpdate1.url(), result.get(0).url()); + assertEquals(linkUpdate2.url(), result.get(1).url()); + } +} diff --git a/bot/src/test/java/backend/academy/bot/kafka/KafkaInvalidLinkProducerTest.java b/bot/src/test/java/backend/academy/bot/kafka/KafkaInvalidLinkProducerTest.java new file mode 100644 index 0000000..7a80fb8 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/kafka/KafkaInvalidLinkProducerTest.java @@ -0,0 +1,98 @@ +package backend.academy.bot.kafka; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import backend.academy.bot.api.dto.kafka.BadLink; +import backend.academy.bot.kafka.client.KafkaInvalidLinkProducer; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.KafkaTemplate; + +@ExtendWith(MockitoExtension.class) +public class KafkaInvalidLinkProducerTest { + + @Mock + private KafkaTemplate kafkaTemplate; + + private KafkaInvalidLinkProducer kafkaInvalidLinkProducer; + + private final String topic = "test-dlq-topic"; + + private Logger logger; + private ListAppender listAppender; + + @BeforeEach + void setup() { + kafkaInvalidLinkProducer = new KafkaInvalidLinkProducer(kafkaTemplate, topic); + + // Настраиваем логгер и ListAppender ЗАРАНЕЕ + logger = (Logger) LoggerFactory.getLogger(KafkaInvalidLinkProducer.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + @AfterEach + void tearDown() { + // Отключаем аппендер после каждого теста, чтобы не мешал другим + logger.detachAppender(listAppender); + } + + @Test + void testSendInvalidLink_SuccessfulSend() { + // given + BadLink badLink = new BadLink(1L, "https://bad-link.com"); + + // when + kafkaInvalidLinkProducer.sendInvalidLink(badLink); + + // then + verify(kafkaTemplate).send(eq(topic), eq(badLink)); + + assertThat(listAppender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly("kafka topic: " + topic, "Сообщение отправлено в kafka"); + } + + @Test + void testSendInvalidLink_FailureSend_LogsError() { + // given + BadLink badLink = new BadLink(1L, "https://bad-link.com"); + doThrow(new RuntimeException("Kafka send failed")).when(kafkaTemplate).send(any(), any()); + + // when + kafkaInvalidLinkProducer.sendInvalidLink(badLink); + + // then + verify(kafkaTemplate).send(eq(topic), eq(badLink)); + + assertThat(listAppender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly("kafka topic: " + topic, "Ошибка при отправки: Kafka send failed"); + } + + @Test + void testSendInvalidLink_ExceptionDoesNotPropagate() { + // given + BadLink badLink = new BadLink(1L, "https://bad-link.com"); + doThrow(new RuntimeException("Kafka error")).when(kafkaTemplate).send(any(), any()); + + // when & then + assertThatNoException().isThrownBy(() -> kafkaInvalidLinkProducer.sendInvalidLink(badLink)); + + assertThat(listAppender.list).extracting(ILoggingEvent::getLevel).contains(Level.ERROR); + } +} diff --git a/bot/src/test/java/backend/academy/bot/kafka/KafkaLinkUpdateListenerUnitTest.java b/bot/src/test/java/backend/academy/bot/kafka/KafkaLinkUpdateListenerUnitTest.java new file mode 100644 index 0000000..5709b61 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/kafka/KafkaLinkUpdateListenerUnitTest.java @@ -0,0 +1,71 @@ +package backend.academy.bot.kafka; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import backend.academy.bot.kafka.client.KafkaLinkUpdateListener; +import backend.academy.bot.notification.NotificationService; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.net.URI; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; + +@ExtendWith(MockitoExtension.class) +public class KafkaLinkUpdateListenerUnitTest { + + @Mock + private NotificationService notificationService; + + private KafkaLinkUpdateListener kafkaLinkUpdateListener; + + @BeforeEach + void setUp() { + kafkaLinkUpdateListener = new KafkaLinkUpdateListener(notificationService); + } + + @Test + void testUpdateConsumerCallsNotificationService() { + // given + LinkUpdate linkUpdate = new LinkUpdate(42L, URI.create("https://test.com"), "some", Collections.emptyList()); + + String topic = "test-link-update-topic"; + + // when + kafkaLinkUpdateListener.updateConsumer(linkUpdate, topic); + + // then + verify(notificationService).sendMessage(linkUpdate); // Проверка вызова сервиса + } + + @Test + void testUpdateConsumerLogsCorrectly() { + // given + LinkUpdate linkUpdate = new LinkUpdate(42L, URI.create("https://test.com"), "some", Collections.emptyList()); + String topic = "my-test-topic"; + + // Мокаем логгер (чтобы получить сообщения) + Logger logger = (Logger) LoggerFactory.getLogger(KafkaLinkUpdateListener.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + // when + kafkaLinkUpdateListener.updateConsumer(linkUpdate, topic); + + // then + verify(notificationService).sendMessage(linkUpdate); + + // Проверяем логи + assertThat(listAppender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly("Получили информацию из топика: " + topic, "Отправили всю информацию из: " + topic); + } +} diff --git a/bot/src/test/java/backend/academy/bot/listener/MessageListenerTest.java b/bot/src/test/java/backend/academy/bot/listener/MessageListenerTest.java new file mode 100644 index 0000000..d388f1b --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/listener/MessageListenerTest.java @@ -0,0 +1,88 @@ +package backend.academy.bot.listener; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import backend.academy.bot.executor.RequestExecutor; +import backend.academy.bot.processor.UserMessageProcessor; +import com.pengrad.telegrambot.UpdatesListener; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class MessageListenerTest { + + @Mock + private RequestExecutor requestExecutor; + + @Mock + private UserMessageProcessor userMessageProcessor; + + private MessageListener messageListener; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + messageListener = new MessageListener(requestExecutor, userMessageProcessor); + } + + @Test + @DisplayName("Обработка валидного сообщения: сообщение отправляется через RequestExecutor") + void testProcess_ValidMessage_SendsResponse() { + // Arrange + Update update = mock(Update.class); + Message message = mock(Message.class); + when(update.message()).thenReturn(message); + when(message.text()).thenReturn("Test message"); + + SendMessage sendMessage = new SendMessage("1", "Test message"); + when(userMessageProcessor.process(update)).thenReturn(sendMessage); + + // Act + int result = messageListener.process(List.of(update)); + + // Assert + verify(userMessageProcessor, times(1)).process(update); + verify(requestExecutor, times(1)).execute(sendMessage); + assertEquals(UpdatesListener.CONFIRMED_UPDATES_ALL, result); + } + + @Test + @DisplayName("Обработка Update с null-сообщением: обработка не происходит") + void testProcess_MessageIsNull_DoesNotProcess() { + // Arrange + Update update = mock(Update.class); + when(update.message()).thenReturn(null); + + // Act + int result = messageListener.process(List.of(update)); + + // Assert + verify(userMessageProcessor, never()).process(any()); + verify(requestExecutor, never()).execute(any()); + assertEquals(UpdatesListener.CONFIRMED_UPDATES_ALL, result); + } + + @Test + @DisplayName("Обработка сообщения: UserMessageProcessor возвращает null, запрос не отправляется") + void testProcess_UserMessageProcessorReturnsNull_DoesNotExecute() { + Update update = mock(Update.class); + Message message = mock(Message.class); + when(update.message()).thenReturn(message); + when(message.text()).thenReturn("Test message"); + + when(userMessageProcessor.process(update)).thenReturn(null); + + int result = messageListener.process(List.of(update)); + + verify(userMessageProcessor, times(1)).process(update); + verify(requestExecutor, never()).execute(any()); + assertEquals(UpdatesListener.CONFIRMED_UPDATES_ALL, result); + } +} diff --git a/bot/src/test/java/backend/academy/bot/message/ParseMessageTest.java b/bot/src/test/java/backend/academy/bot/message/ParseMessageTest.java new file mode 100644 index 0000000..4d80836 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/message/ParseMessageTest.java @@ -0,0 +1,141 @@ +package backend.academy.bot.message; + +import static org.junit.jupiter.api.Assertions.*; + +import backend.academy.bot.api.dto.request.tag.TagRemoveRequest; +import backend.academy.bot.exception.InvalidInputFormatException; +import backend.academy.bot.state.UserState; +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ParseMessageTest { + + private final ParserMessage parser = new ParserMessage(); + + @Test + @DisplayName("Парсинг URL с командой /track - валидный URL") + void parseUrl_ValidUrlWithTrackCommand_ReturnsURI() { + URI result = parser.parseUrl("/track https://github.com/user/repo", UserState.WAITING_URL); + assertEquals("https://github.com/user/repo", result.toString()); + } + + @Test + @DisplayName("Парсинг URL в состоянии WAITING_URL - только URL") + void parseUrl_OnlyUrlInWaitingState_ReturnsURI() { + URI result = parser.parseUrl("https://stackoverflow.com/questions", UserState.WAITING_URL); + assertEquals("https://stackoverflow.com/questions", result.toString()); + } + + @Test + @DisplayName("Парсинг URL - неверный формат URL") + void parseUrl_InvalidUrlFormat_ThrowsException() { + assertThrows( + InvalidInputFormatException.class, () -> parser.parseUrl("/track invalid_url", UserState.WAITING_URL)); + } + + @Test + @DisplayName("Парсинг URL - неподдерживаемый домен") + void parseUrl_UnsupportedDomain_ThrowsException() { + assertThrows( + InvalidInputFormatException.class, + () -> parser.parseUrl("/track http://google.com", UserState.WAITING_URL)); + } + + @Test + @DisplayName("Парсинг команды /untrack - валидный URL") + void parseUrl_ValidUntrackCommand_ReturnsURI() { + URI result = parser.parseUrl("/untrack https://github.com/user/repo"); + assertEquals("https://github.com/user/repo", result.toString()); + } + + @Test + @DisplayName("Парсинг команды /untrack - неверный формат") + void parseUrl_InvalidUntrackFormat_ThrowsException() { + assertThrows(InvalidInputFormatException.class, () -> parser.parseUrl("/untrack")); + } + + @Test + @DisplayName("Получение дополнительных атрибутов - валидный ввод") + void getAdditionalAttribute_ValidInput_ReturnsList() { + List result = parser.getAdditionalAttribute("arg1 arg2 arg3"); + assertEquals(List.of("arg1", "arg2", "arg3"), result); + } + + @Test + @DisplayName("Получение дополнительных атрибутов - пустой ввод") + void getAdditionalAttribute_EmptyInput_ThrowsException() { + assertThrows(InvalidInputFormatException.class, () -> parser.getAdditionalAttribute("")); + } + + @Test + @DisplayName("Парсинг команды /tag - валидный тег") + void parseMessageTag_ValidCommand_ReturnsTag() { + String tag = parser.parseMessageTag("/tag mytag"); + assertEquals("mytag", tag); + } + + @ParameterizedTest + @ValueSource(strings = {"/tag", "/tag ", "/tag mytag extra"}) + @DisplayName("Парсинг команды /tag - неверные форматы") + void parseMessageTag_InvalidFormats_ThrowsException(String input) { + assertThrows(InvalidInputFormatException.class, () -> parser.parseMessageTag(input)); + } + + @Test + @DisplayName("Парсинг команды /taglist - валидная команда") + void parseMessageTagList_ValidCommand_NoException() { + assertDoesNotThrow(() -> parser.parseMessageTagList("/taglist")); + } + + @Test + @DisplayName("Парсинг команды /taglist - с аргументами") + void parseMessageTagList_WithArguments_ThrowsException() { + assertThrows(InvalidInputFormatException.class, () -> parser.parseMessageTagList("/taglist arg")); + } + + @Test + @DisplayName("Парсинг команды /untag - валидный запрос") + void parseMessageUnTag_ValidCommand_ReturnsRequest() { + TagRemoveRequest request = parser.parseMessageUnTag("/untag mytag https://github.com"); + assertEquals("mytag", request.tag()); + assertEquals("https://github.com", request.uri().toString()); + } + + @ParameterizedTest + @ValueSource( + strings = {"/untag", "/untag mytag", "/untag mytag invalid_url", "invalid_cmd mytag https://github.com"}) + @DisplayName("Парсинг команды /untag - неверные форматы") + void parseMessageUnTag_InvalidFormats_ThrowsException(String input) { + assertThrows(InvalidInputFormatException.class, () -> parser.parseMessageUnTag(input)); + } + + @Test + @DisplayName("Парсинг команды /filter - валидный фильтр") + void parseMessageFilter_ValidCommand_ReturnsFilter() { + String filter = parser.parseMessageFilter("/filter java", "error"); + assertEquals("java", filter); + } + + @ParameterizedTest + @ValueSource(strings = {"/filter", "/filter ", "invalid"}) + @DisplayName("Парсинг команды /filter - неверные форматы") + void parseMessageFilter_InvalidFormats_ThrowsException(String input) { + assertThrows(InvalidInputFormatException.class, () -> parser.parseMessageFilter(input, "Custom error")); + } + + @Test + @DisplayName("Парсинг команды /filterlist - валидная команда") + void parseMessageFilterList_ValidCommand_NoException() { + assertDoesNotThrow(() -> parser.parseMessageFilterList("/filterlist")); + } + + @Test + @DisplayName("Парсинг команды /filterlist - с аргументами") + void parseMessageFilterList_WithArguments_ThrowsException() { + assertThrows(InvalidInputFormatException.class, () -> parser.parseMessageFilterList("/filterlist arg")); + } +} diff --git a/bot/src/test/java/backend/academy/bot/processor/UserMessageProcessorTest.java b/bot/src/test/java/backend/academy/bot/processor/UserMessageProcessorTest.java new file mode 100644 index 0000000..31ea369 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/processor/UserMessageProcessorTest.java @@ -0,0 +1,96 @@ +package backend.academy.bot.processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import backend.academy.bot.command.Command; +import backend.academy.bot.command.link.TrackCommand; +import backend.academy.bot.state.UserState; +import backend.academy.bot.state.UserStateManager; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.model.Chat; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class UserMessageProcessorTest { + + @Mock + private TelegramBot telegramBot; + + @Mock + private Command command1; + + @Mock + private TrackCommand trackCommand; + + @Mock + private UserStateManager userStateManager; + + private UserMessageProcessor userMessageProcessor; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + userMessageProcessor = new UserMessageProcessor(telegramBot, List.of(command1, trackCommand), userStateManager); + } + + @Test + @DisplayName("Обработка сообщения: команда найдена и обработана") + void testProcess_CommandFoundAndHandled() { + Update update = createUpdateWithText("/mock"); + when(command1.matchesCommand(update)).thenReturn(true); + when(command1.handle(update)).thenReturn(new SendMessage(123L, "Mock message")); + + SendMessage result = userMessageProcessor.process(update); + verify(command1, times(1)).matchesCommand(update); + verify(command1, times(1)).handle(update); + assertEquals("Mock message", result.getParameters().get("text")); + } + + @Test + @DisplayName("Обработка сообщения: команда не найдена, состояние WAITING_URL") + void testProcess_NoCommandFound_WaitingUrlState() { + Update update = createUpdateWithText("https://github.com/example"); + when(command1.matchesCommand(update)).thenReturn(false); + when(userStateManager.getUserState(123L)).thenReturn(UserState.WAITING_URL); + when(trackCommand.handle(update)).thenReturn(new SendMessage(123L, "Track command handled")); + + SendMessage result = userMessageProcessor.process(update); + + verify(command1, times(1)).matchesCommand(update); + verify(trackCommand, times(1)).handle(update); + assertEquals("Track command handled", result.getParameters().get("text")); + } + + @Test + @DisplayName("Обработка сообщения: пользователь создается, если не существует") + void testProcess_UserCreatedIfNotExist() { + Update update = createUpdateWithText("/start"); + when(command1.matchesCommand(update)).thenReturn(true); + when(command1.handle(update)).thenReturn(new SendMessage(123L, "User created")); + + userMessageProcessor.process(update); + + verify(userStateManager, times(1)).createUserIfNotExist(123L); + } + + private Update createUpdateWithText(String text) { + Update update = mock(Update.class); + Message message = mock(Message.class); + Chat chat = mock(Chat.class); + + when(update.message()).thenReturn(message); + when(message.chat()).thenReturn(chat); + when(chat.id()).thenReturn(123L); + when(message.text()).thenReturn(text); + + return update; + } +} diff --git a/bot/src/test/java/backend/academy/bot/redis/RedisCacheServiceTest.java b/bot/src/test/java/backend/academy/bot/redis/RedisCacheServiceTest.java new file mode 100644 index 0000000..344dd93 --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/redis/RedisCacheServiceTest.java @@ -0,0 +1,85 @@ +package backend.academy.bot.redis; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import backend.academy.bot.api.dto.response.ListLinksResponse; +import java.util.Collections; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class RedisCacheServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private RedisCacheService redisCacheService; + + private final Long chatId = 12345L; + private final ListLinksResponse testResponse = new ListLinksResponse(Collections.emptyList(), 0); + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + redisCacheService = new RedisCacheService(redisTemplate); + } + + @Test + @DisplayName("Сохранение данных в кеш") + void cacheLinks_shouldSaveDataToCache() { + // Act + redisCacheService.cacheLinks(chatId, testResponse); + + // Assert + verify(valueOperations).set("bot:links:12345", testResponse); + } + + @Test + @DisplayName("Получение данных из кеша") + void getCachedLinks_shouldReturnCachedData() { + // Arrange + when(valueOperations.get("bot:links:12345")).thenReturn(testResponse); + + // Act + ListLinksResponse result = redisCacheService.getCachedLinks(chatId); + + // Assert + assertEquals(testResponse, result); + verify(valueOperations).get("bot:links:12345"); + } + + @Test + @DisplayName("Получение null при отсутствии данных в кеше") + void getCachedLinks_shouldReturnNullWhenCacheEmpty() { + // Arrange + when(valueOperations.get("bot:links:12345")).thenReturn(null); + + // Act + ListLinksResponse result = redisCacheService.getCachedLinks(chatId); + + // Assert + assertNull(result); + verify(valueOperations).get("bot:links:12345"); + } + + @Test + @DisplayName("Очистка кеша для конкретного chatId") + void invalidateCache_shouldDeleteCacheForSpecificChatId() { + redisCacheService.invalidateCache(chatId); + Assertions.assertNull(redisCacheService.getCachedLinks(chatId)); + } +} diff --git a/bot/src/test/java/backend/academy/bot/redis/RedisMessageServiceTest.java b/bot/src/test/java/backend/academy/bot/redis/RedisMessageServiceTest.java new file mode 100644 index 0000000..2c88c1c --- /dev/null +++ b/bot/src/test/java/backend/academy/bot/redis/RedisMessageServiceTest.java @@ -0,0 +1,124 @@ +package backend.academy.bot.redis; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import backend.academy.bot.api.dto.request.LinkUpdate; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class RedisMessageServiceTest { + + @Mock + private RedisTemplate> redisTemplate; + + @Mock + private ValueOperations> valueOperations; + + private RedisMessageService redisMessageService; + + private final LinkUpdate linkUpdate1 = + new LinkUpdate(1L, URI.create("https://github.com"), "desc1", new ArrayList<>()); + private final LinkUpdate linkUpdate2 = + new LinkUpdate(2L, URI.create("https://github.com"), "desc2", new ArrayList<>()); + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + redisMessageService = new RedisMessageService(redisTemplate); + } + + @Test + @DisplayName("Добавление ссылки в пустой кеш") + void addCacheLinks_shouldAddNewLinkToEmptyCache() { + // Arrange + when(valueOperations.get(anyString())).thenReturn(null); + + // Act + redisMessageService.addCacheLinks(linkUpdate1); + + // Assert + verify(valueOperations).get("bot:notifications"); + verify(valueOperations) + .set( + eq("bot:notifications"), + argThat(list -> + list != null && list.size() == 1 && list.get(0).equals(linkUpdate1))); + } + + @Test + @DisplayName("Добавление ссылки в существующий кеш") + void addCacheLinks_shouldAddNewLinkToExistingCache() { + // Arrange + List existingList = new ArrayList<>(List.of(linkUpdate1)); + when(valueOperations.get(anyString())).thenReturn(existingList); + + // Act + redisMessageService.addCacheLinks(linkUpdate2); + + // Assert + verify(valueOperations).get("bot:notifications"); + verify(valueOperations) + .set( + eq("bot:notifications"), + argThat(list -> list != null + && list.size() == 2 + && list.contains(linkUpdate1) + && list.contains(linkUpdate2))); + } + + @Test + @DisplayName("Получение данных из кеша") + void getCachedLinks_shouldReturnCachedLinks() { + // Arrange + List expectedList = Arrays.asList(linkUpdate1, linkUpdate2); + when(valueOperations.get("bot:notifications")).thenReturn(expectedList); + + // Act + List result = redisMessageService.getCachedLinks(); + + // Assert + assertEquals(expectedList, result); + verify(valueOperations).get("bot:notifications"); + } + + @Test + @DisplayName("Получение null при пустом кеше") + void getCachedLinks_shouldReturnNullWhenCacheEmpty() { + // Arrange + when(valueOperations.get("bot:notifications")).thenReturn(null); + + // Act + List result = redisMessageService.getCachedLinks(); + + // Assert + assertNull(result); + verify(valueOperations).get("bot:notifications"); + } + + @Test + @DisplayName("Очистка кеша") + void invalidateCache_shouldDeleteKey() { + // Act + redisMessageService.invalidateCache(); + + // Assert + + List list = redisMessageService.getCachedLinks(); + + assertNull(list); + } +} diff --git a/bot/src/test/resources/application-test.yaml b/bot/src/test/resources/application-test.yaml new file mode 100644 index 0000000..b0c30c1 --- /dev/null +++ b/bot/src/test/resources/application-test.yaml @@ -0,0 +1,51 @@ +app: + message-transport: kafka + topic: "updated-topic" + producer-client-id: producerId + webclient: + timeouts: + connect-timeout: PT10S # 10 секунд в ISO-8601 формате + response-timeout: PT10S + global-timeout: PT20S + link: + scrapper-uri: "http://localhost:8081" + + +spring: + application: + bootstrap-servers: "localhost:29092" + producer: + properties: + spring.json.add.type.headers: false + + + +resilience4j: + retry: + configs: + default: + max-attempts: 3 + wait-duration: 3s + retry-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException + ignore-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest + instances: + registerChat: + base-config: default + deleteChat: + base-config: default + + circuitbreaker: + configs: + default: + 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: 10s + ignore-exceptions: + - org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest + instances: + ScrapperChatClient: + base-config: default diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..07dd8b9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,117 @@ +version: "3.8" + +services: + postgresql: + image: postgres:latest + ports: + - "5433:5432" + container_name: scrapper_db + environment: + POSTGRES_DB: scrapper_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + networks: + - backend + volumes: + - postgresql:/var/lib/postgresql/data + + liquibase-migrations: + container_name: migrations + image: liquibase/liquibase:4.29 + depends_on: + - postgresql + command: + - --searchPath=/changesets + - --changelog-file=master.xml + - --driver=org.postgresql.Driver + - --url=jdbc:postgresql://postgresql:5432/scrapper_db + - --username=postgres + - --password=postgres + - update + volumes: + - ./migrations:/changesets + networks: + - backend + + redis: + image: redis:7.4.2 + ports: + - "6379:6379" + volumes: + - redis:/data + networks: + - backend + + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: ["CMD-SHELL", "echo stat | nc localhost 2181 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - zookeeper:/var/lib/zookeeper/data + networks: + - kafka-net + + broker: + image: confluentinc/cp-kafka:7.5.0 + container_name: broker + restart: unless-stopped + ports: + - "9092:9092" + - "29092:29092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + depends_on: + zookeeper: + condition: service_healthy + volumes: + - broker:/var/lib/kafka/data + networks: + - kafka-net + healthcheck: + test: ["CMD-SHELL", "kafka-topics --bootstrap-server localhost:9092 --list || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + kafka-ui: + container_name: kafka-ui + image: provectuslabs/kafka-ui:latest + ports: + - "8086:8080" + depends_on: + - broker + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: broker:9092 + DYNAMIC_CONFIG_ENABLED: 'true' + networks: + - kafka-net + +volumes: + postgresql: + redis: + zookeeper: + broker: + +networks: + backend: + driver: bridge + kafka-net: + driver: bridge diff --git a/migrations/00-initial-schema.sql b/migrations/00-initial-schema.sql new file mode 100644 index 0000000..a380a1e --- /dev/null +++ b/migrations/00-initial-schema.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS tg_chats ( + id BIGINT PRIMARY KEY, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Таблица для хранения ссылок +CREATE TABLE IF NOT EXISTS links ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + url TEXT NOT NULL, + description TEXT, + updated_at TIMESTAMP WITHOUT TIME ZONE +); + +-- Таблица для хранения фильтров +CREATE TABLE IF NOT EXISTS filters ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + link_id BIGINT REFERENCES links(id) ON DELETE CASCADE, + filter TEXT NOT NULL +); + +-- Таблица для хранения тегов (нормализованная структура) +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + link_id BIGINT REFERENCES links(id) ON DELETE CASCADE, + tag TEXT NOT NULL +); + +-- Таблица для связи чатов и ссылок +CREATE TABLE IF NOT EXISTS tg_chat_links ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + tg_chat_id BIGINT REFERENCES tg_chats(id) ON DELETE CASCADE, + link_id BIGINT REFERENCES links(id) ON DELETE CASCADE +); + +-- Индексы для ускорения запросов +CREATE INDEX idx_tg_chat_links_tg_chat_id ON tg_chat_links(tg_chat_id); +CREATE INDEX idx_tg_chat_links_link_id ON tg_chat_links(link_id); +CREATE INDEX idx_filters_filter ON filters(filter); +CREATE INDEX idx_tags_tag ON tags(tag); diff --git a/migrations/01-add-filterlist-table.sql b/migrations/01-add-filterlist-table.sql new file mode 100644 index 0000000..84ab8a6 --- /dev/null +++ b/migrations/01-add-filterlist-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS access_filter ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + tg_chat_id BIGINT REFERENCES tg_chats(id) ON DELETE CASCADE, + filter TEXT NOT NULL +); + +CREATE INDEX idx_access_filter_tg_chat_id ON tg_chat_links(tg_chat_id); + diff --git a/migrations/master.xml b/migrations/master.xml new file mode 100644 index 0000000..0d53cd8 --- /dev/null +++ b/migrations/master.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/pom.xml b/pom.xml index 5260f7e..c9ed5d0 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,8 @@ 1.0 + 2.1.0 + 3.8.8 23 @@ -64,6 +66,7 @@ 4.8.6.6 LATEST 1.5.0 + @@ -127,6 +130,15 @@ bucket4j-spring-boot-starter ${bucket4j-spring-boot-starter.version} + + + io.github.resilience4j + resilience4j-bom + ${resilience4j.version} + pom + import + + @@ -166,6 +178,7 @@ wiremock-standalone test + @@ -191,6 +204,8 @@ org.projectlombok lombok + + 1.18.36 com.google.errorprone @@ -432,6 +447,7 @@ + diff --git a/scrapper/pom.xml b/scrapper/pom.xml index a3e67b8..eece8f5 100644 --- a/scrapper/pom.xml +++ b/scrapper/pom.xml @@ -32,28 +32,35 @@ springdoc-openapi-starter-webmvc-ui - - - - - - - - - - - - - - - - - - - - - - + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.liquibase + liquibase-core + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + runtime + @@ -62,10 +69,10 @@ - - - - + + org.springframework.kafka + spring-kafka + @@ -135,11 +142,27 @@ kafka test - - - - - + + org.springframework.kafka + spring-kafka-test + test + + + + io.github.resilience4j + resilience4j-spring-boot3 + + + io.github.resilience4j + resilience4j-reactor + + + + com.bucket4j + bucket4j-core + 8.7.0 + + diff --git a/scrapper/src/main/java/backend/academy/scrapper/ScrapperApplication.java b/scrapper/src/main/java/backend/academy/scrapper/ScrapperApplication.java index 525d500..d73cec5 100644 --- a/scrapper/src/main/java/backend/academy/scrapper/ScrapperApplication.java +++ b/scrapper/src/main/java/backend/academy/scrapper/ScrapperApplication.java @@ -1,12 +1,18 @@ package backend.academy.scrapper; +import backend.academy.scrapper.configuration.SchedulerConfig; +import backend.academy.scrapper.configuration.ScrapperConfig; +import backend.academy.scrapper.limit.RateLimitProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableConfigurationProperties({ScrapperConfig.class}) +@EnableConfigurationProperties({ScrapperConfig.class, SchedulerConfig.class, RateLimitProperties.class}) +@EnableScheduling public class ScrapperApplication { + public static void main(String[] args) { SpringApplication.run(ScrapperApplication.class, args); } diff --git a/scrapper/src/main/java/backend/academy/scrapper/ScrapperConfig.java b/scrapper/src/main/java/backend/academy/scrapper/ScrapperConfig.java deleted file mode 100644 index b999760..0000000 --- a/scrapper/src/main/java/backend/academy/scrapper/ScrapperConfig.java +++ /dev/null @@ -1,11 +0,0 @@ -package backend.academy.scrapper; - -import jakarta.validation.constraints.NotEmpty; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Validated -@ConfigurationProperties(prefix = "app", ignoreUnknownFields = false) -public record ScrapperConfig(@NotEmpty String githubToken, StackOverflowCredentials stackOverflow) { - public record StackOverflowCredentials(@NotEmpty String key, @NotEmpty String accessToken) {} -} diff --git a/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClient.java b/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClient.java new file mode 100644 index 0000000..7a7e177 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClient.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.client; + +import backend.academy.scrapper.tracker.update.model.LinkUpdate; + +public interface TgBotClient { + void sendUpdate(LinkUpdate linkUpdate); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClientImpl.java b/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClientImpl.java new file mode 100644 index 0000000..64b4394 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/client/TgBotClientImpl.java @@ -0,0 +1,55 @@ +package backend.academy.scrapper.client; + +import backend.academy.scrapper.client.type.HttpUpdateSender; +import backend.academy.scrapper.client.type.KafkaUpdateSender; +import backend.academy.scrapper.tracker.update.model.LinkUpdate; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TgBotClientImpl implements TgBotClient { + + private final HttpUpdateSender httpUpdateSender; + private final KafkaUpdateSender kafkaUpdateSender; + + private static final String HTTP_TRANSPORT = "http"; + private static final String KAFKA_TRANSPORT = "kafka"; + + @Value("${app.message-transport}") + private String typeUpdateSender; + + @CircuitBreaker(name = "tgBotClient", fallbackMethod = "sendUpdateFallBack") + @Override + public void sendUpdate(LinkUpdate linkUpdate) { + log.info("##### Пошли в http"); + if (HTTP_TRANSPORT.equals(typeUpdateSender) + || HTTP_TRANSPORT.toUpperCase(Locale.ROOT).equals(typeUpdateSender)) { + httpUpdateSender.sendUpdate(linkUpdate); + } else if (KAFKA_TRANSPORT.equals(typeUpdateSender) + || KAFKA_TRANSPORT.toUpperCase(Locale.ROOT).equals(typeUpdateSender)) { + log.info("##### Пошли в kafka"); + kafkaUpdateSender.sendUpdate(linkUpdate); + } else { + log.error("Unknown update type: {}", linkUpdate); + throw new RuntimeException("Unknown update type: " + linkUpdate); + } + } + + public void sendUpdateFallBack(LinkUpdate linkUpdate, Exception ex) { + log.error("Ошибка транспорта, меняем его"); + if (HTTP_TRANSPORT.equals(typeUpdateSender) + || HTTP_TRANSPORT.toUpperCase(Locale.ROOT).equals(typeUpdateSender)) { + log.info("Значит отправляем в KAFKA"); + kafkaUpdateSender.sendUpdate(linkUpdate); + } else { + log.info("Значит отправляем по HTTP"); + httpUpdateSender.sendUpdate(linkUpdate); + } + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/client/type/HttpUpdateSender.java b/scrapper/src/main/java/backend/academy/scrapper/client/type/HttpUpdateSender.java new file mode 100644 index 0000000..207a6cd --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/client/type/HttpUpdateSender.java @@ -0,0 +1,55 @@ +package backend.academy.scrapper.client.type; + +import backend.academy.scrapper.configuration.api.WebClientProperties; +import backend.academy.scrapper.tracker.update.model.LinkUpdate; +import io.github.resilience4j.retry.annotation.Retry; +import io.netty.channel.ChannelOption; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +@Component +@Slf4j +public class HttpUpdateSender implements UpdateSender { + + private final WebClient webClient; + private final WebClientProperties webClientProperties; + + public HttpUpdateSender( + @Value("${app.link.telegram-bot-uri}") String baseUrl, WebClientProperties webClientProperties) { + this.webClientProperties = webClientProperties; + 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(); + } + + @Retry(name = "httpSendUpdate", fallbackMethod = "sendUpdateFallback") + @Override + public void sendUpdate(LinkUpdate linkUpdate) { + log.info("Отправка обновления: {}", linkUpdate.url()); + webClient + .post() + .uri("/updates") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(linkUpdate) + .retrieve() + .toBodilessEntity() + .timeout(webClientProperties.globalTimeout()) + .block(); + } + + public void sendUpdateFallback(LinkUpdate linkUpdate, Exception ex) { + log.error("HttpUpdateSender не работает HTTP: "); + throw new RuntimeException("HTTP не работает"); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/client/type/KafkaUpdateSender.java b/scrapper/src/main/java/backend/academy/scrapper/client/type/KafkaUpdateSender.java new file mode 100644 index 0000000..2d6d123 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/client/type/KafkaUpdateSender.java @@ -0,0 +1,31 @@ +package backend.academy.scrapper.client.type; + +import backend.academy.scrapper.tracker.update.model.LinkUpdate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class KafkaUpdateSender implements UpdateSender { + + private final KafkaTemplate kafkaTemplate; + + @Value("${app.topic}") + private String topic; + + @Override + public void sendUpdate(LinkUpdate linkUpdate) { + log.info("Kafka TOPIC:"); + try { + kafkaTemplate.send(topic, linkUpdate); + log.info("Сообщение отправлено в kafka"); + } catch (RuntimeException e) { + log.error("Ошибка при отправки: {}", e.getMessage()); + throw new RuntimeException("Ошибка отправки в kafka"); + } + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/client/type/UpdateSender.java b/scrapper/src/main/java/backend/academy/scrapper/client/type/UpdateSender.java new file mode 100644 index 0000000..2197e7c --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/client/type/UpdateSender.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.client.type; + +import backend.academy.scrapper.tracker.update.model.LinkUpdate; + +public interface UpdateSender { + void sendUpdate(LinkUpdate linkUpdate); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/KafkaProducerConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/KafkaProducerConfig.java new file mode 100644 index 0000000..1c66141 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/KafkaProducerConfig.java @@ -0,0 +1,50 @@ +package backend.academy.scrapper.configuration; + +import backend.academy.scrapper.tracker.update.model.LinkUpdate; +import java.util.Map; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +@Configuration +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${app.topic}") + private String topicName; + + @Value("${app.producer-client-id}") + private String producerClientId; + + @Bean + public NewTopic topic() { + return TopicBuilder.name(topicName).partitions(1).replicas(1).build(); + } + + @Bean + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + Map configProps = kafkaProperties.buildProducerProperties(null); + + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ProducerConfig.CLIENT_ID_CONFIG, producerClientId); + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/SchedulerConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/SchedulerConfig.java new file mode 100644 index 0000000..ddbd6f8 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/SchedulerConfig.java @@ -0,0 +1,10 @@ +package backend.academy.scrapper.configuration; + +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "scheduler", ignoreUnknownFields = true) +public record SchedulerConfig(boolean enable, @NotNull Duration interval, @NotNull Duration forceCheckDelay) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/ScrapperConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/ScrapperConfig.java new file mode 100644 index 0000000..41589d9 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/ScrapperConfig.java @@ -0,0 +1,15 @@ +package backend.academy.scrapper.configuration; + +import jakarta.validation.constraints.NotEmpty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app", ignoreUnknownFields = true) +public record ScrapperConfig(GithubCredentials github, StackOverflowCredentials stackOverflow) { + + public record GithubCredentials(@NotEmpty String githubToken, @NotEmpty String githubUrl) {} + + public record StackOverflowCredentials( + @NotEmpty String key, @NotEmpty String accessToken, @NotEmpty String stackOverFlowUrl) {} +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/api/ClientConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/api/ClientConfig.java new file mode 100644 index 0000000..486437a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/api/ClientConfig.java @@ -0,0 +1,22 @@ +package backend.academy.scrapper.configuration.api; + +import backend.academy.scrapper.configuration.ScrapperConfig; +import backend.academy.scrapper.tracker.client.GitHubClient; +import backend.academy.scrapper.tracker.client.StackOverFlowClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClientConfig { + + @Bean + public GitHubClient createGitHubClient(ScrapperConfig scrapperConfig, WebClientProperties webClientProperties) { + return new GitHubClient(scrapperConfig.github(), webClientProperties); + } + + @Bean + public StackOverFlowClient createStackOverFlowClient( + ScrapperConfig scrapperConfig, WebClientProperties webClientProperties) { + return new StackOverFlowClient(scrapperConfig.stackOverflow(), webClientProperties); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/api/WebClientProperties.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/api/WebClientProperties.java new file mode 100644 index 0000000..8bcca51 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/api/WebClientProperties.java @@ -0,0 +1,22 @@ +package backend.academy.scrapper.configuration.api; + +import jakarta.validation.constraints.Positive; +import java.time.Duration; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "webclient.timeouts") +@Getter +public class WebClientProperties { + // Дефолтное заполнение + @Positive + private Duration connectTimeout = Duration.ofSeconds(5); + + @Positive + private Duration responseTimeout = Duration.ofSeconds(5); + + @Positive + private Duration globalTimeout = Duration.ofSeconds(15); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JdbcServiceConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JdbcServiceConfig.java new file mode 100644 index 0000000..d05db16 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JdbcServiceConfig.java @@ -0,0 +1,53 @@ +package backend.academy.scrapper.configuration.db; + +import backend.academy.scrapper.dao.TgChatLinkDao; +import backend.academy.scrapper.dao.TgChatLinkDaoImpl; +import backend.academy.scrapper.dao.accessfilter.AccessFilterDao; +import backend.academy.scrapper.dao.chat.TgChatDaoImpl; +import backend.academy.scrapper.dao.filter.FilterDao; +import backend.academy.scrapper.dao.link.LinkDao; +import backend.academy.scrapper.dao.link.LinkDaoImpl; +import backend.academy.scrapper.dao.tag.TagDao; +import backend.academy.scrapper.mapper.LinkMapper; +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 backend.academy.scrapper.service.jdbc.JdbcAccessFilterService; +import backend.academy.scrapper.service.jdbc.JdbcChatService; +import backend.academy.scrapper.service.jdbc.JdbcLinkService; +import backend.academy.scrapper.service.jdbc.JdbcTagService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@ConditionalOnProperty(prefix = "app", name = "database-access-type", havingValue = "jdbc") +public class JdbcServiceConfig { + @Bean + @Profile("jdbc") + ChatService chatService(TgChatDaoImpl chatDao) { + return new JdbcChatService(chatDao); + } + + @Bean + @Profile("jdbc") + LinkService linkService( + TgChatDaoImpl chatDao, LinkDaoImpl linkDao, TgChatLinkDaoImpl chatLinkDao, LinkMapper linkMapper) { + return new JdbcLinkService(chatDao, linkDao, chatLinkDao, linkMapper); + } + + @Bean + @Profile("jdbc") + TagService tagService( + FilterDao filterDao, TagDao tagDao, LinkDao linkDao, TgChatLinkDao tgChatLinkDao, LinkMapper linkMapper) { + return new JdbcTagService(filterDao, tagDao, linkDao, tgChatLinkDao, linkMapper); + } + + @Bean + @Profile("jdbc") + AccessFilterService accessFilterService(AccessFilterDao accessFilterDao) { + return new JdbcAccessFilterService(accessFilterDao); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JpaConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JpaConfig.java new file mode 100644 index 0000000..d6efc5d --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/JpaConfig.java @@ -0,0 +1,10 @@ +package backend.academy.scrapper.configuration.db; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaRepositories("backend.academy.scrapper.repository") +@EntityScan("backend.academy.scrapper.entity") +public class JpaConfig {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/configuration/db/OrmServiceConfig.java b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/OrmServiceConfig.java new file mode 100644 index 0000000..52c7ef5 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/configuration/db/OrmServiceConfig.java @@ -0,0 +1,56 @@ +package backend.academy.scrapper.configuration.db; + +import backend.academy.scrapper.mapper.FilterMapper; +import backend.academy.scrapper.mapper.LinkMapper; +import backend.academy.scrapper.repository.AccessFilterRepository; +import backend.academy.scrapper.repository.LinkRepository; +import backend.academy.scrapper.repository.TgChatLinkRepository; +import backend.academy.scrapper.repository.TgChatRepository; +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 backend.academy.scrapper.service.orm.OrmAccessFilterService; +import backend.academy.scrapper.service.orm.OrmChatService; +import backend.academy.scrapper.service.orm.OrmLinkService; +import backend.academy.scrapper.service.orm.OrmTagService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@ConditionalOnProperty(prefix = "app", name = "database-access-type", havingValue = "orm") +public class OrmServiceConfig { + + @Bean + @Profile("orm") + ChatService chatService(TgChatRepository tgChatRepository) { + return new OrmChatService(tgChatRepository); + } + + @Bean + @Profile("orm") + LinkService linkService( + LinkRepository linkRepository, + TgChatLinkRepository tgChatLinkRepository, + LinkMapper mapper, + ChatService chatService) { + return new OrmLinkService(linkRepository, tgChatLinkRepository, mapper, chatService); + } + + @Bean + @Profile("orm") + TagService tagService(LinkService linkService, TgChatLinkRepository tgChatLinkRepository, LinkMapper linkMapper) { + return new OrmTagService(linkService, tgChatLinkRepository, linkMapper); + } + + @Bean + @Profile("orm") + AccessFilterService accessFilterService( + AccessFilterRepository accessFilterRepository, + TgChatRepository tgChatRepository, + FilterMapper filterMapper) { + return new OrmAccessFilterService(tgChatRepository, accessFilterRepository, filterMapper); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/controller/ChatController.java b/scrapper/src/main/java/backend/academy/scrapper/controller/ChatController.java new file mode 100644 index 0000000..4800a71 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/controller/ChatController.java @@ -0,0 +1,43 @@ +package backend.academy.scrapper.controller; + +import backend.academy.scrapper.service.ChatService; +import backend.academy.scrapper.util.Utils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@Slf4j +@RestController +@RequestMapping("/tg-chat") +public class ChatController { + + private final ChatService chatService; + + @Operation(summary = "Зарегистрировать чат") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Чат зарегистрирован")}) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/{id}") + public void registerChat(@PathVariable Long id) { + log.info("ChatController registerChat {}", Utils.sanitize(id)); + chatService.registerChat(id); + } + + @Operation(summary = "Удалить чат") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Чат успешно удалён")}) + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/{id}") + public void deleteChat(@PathVariable Long id) { + log.info("ChatController deleteChat {}", Utils.sanitize(id)); + chatService.deleteChat(id); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/controller/FilterController.java b/scrapper/src/main/java/backend/academy/scrapper/controller/FilterController.java new file mode 100644 index 0000000..692fb70 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/controller/FilterController.java @@ -0,0 +1,61 @@ +package backend.academy.scrapper.controller; + +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/filter") +@Slf4j +@RequiredArgsConstructor +public class FilterController { + + private final AccessFilterService accessFilterService; + + @PostMapping("/{tgChatId}") + @ResponseStatus(HttpStatus.CREATED) + public FilterResponse createFilter(@PathVariable Long tgChatId, @RequestBody FilterRequest filterRequest) { + log.info("POST /filter/{tgChatId}"); + return accessFilterService.createFilter(tgChatId, filterRequest); + } + + @GetMapping("/{tgChatId}") + @ResponseStatus(HttpStatus.OK) + public FilterListResponse getAllFilter(@PathVariable Long tgChatId) { + log.info("GET /filter/{tgChatId}"); + return accessFilterService.getAllFilter(tgChatId); + } + + @DeleteMapping("/{tgChatId}") + @ResponseStatus(HttpStatus.OK) + public FilterResponse deleteFilter(@PathVariable Long tgChatId, @RequestBody FilterRequest filterRequest) { + log.info("DELETE /filter/{tgChatId}"); + return accessFilterService.deleteFilter(tgChatId, filterRequest); + } +} +// 70% вероятность исключения +// if (Math.random() < 0.5) { +// log.info("INTERNAL_SERVER_ERROR"); +// +// throw new HttpServerErrorException( +// HttpStatus.INTERNAL_SERVER_ERROR, "Серверная ошибка (тестовая, 70% вероятность)"); +// } +// +// if (Math.random() < 0.5) { +// log.info("NOT_FOUND"); +// +// throw new HttpClientErrorException(HttpStatus.NOT_FOUND, "Сервер сломался по-настоящему"); +// } +// log.info("ResponseException"); diff --git a/scrapper/src/main/java/backend/academy/scrapper/controller/LinkController.java b/scrapper/src/main/java/backend/academy/scrapper/controller/LinkController.java new file mode 100644 index 0000000..ca11abe --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/controller/LinkController.java @@ -0,0 +1,62 @@ +package backend.academy.scrapper.controller; + +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 backend.academy.scrapper.util.Utils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/links") +public class LinkController { + + private final LinkService linkService; + + @Operation(summary = "Получить все отслеживаемые ссылки") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Ссылки успешно получены")}) + @ResponseStatus(HttpStatus.OK) + @GetMapping + public ListLinksResponse getAllLinks(@RequestHeader(value = "Tg-Chat-Id") Long tgChatId) { + log.info("LinkController getAllLinks {} ", Utils.sanitize(tgChatId)); + return linkService.findAllLinksByChatId(tgChatId); + } + + @Operation(summary = "Добавить отслеживание ссылки") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Ссылка успешно добавлена")}) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/{tgChatId}") + public LinkResponse addLink( + @RequestHeader(value = "Tg-Chat-Id") Long tgChatId, @RequestBody AddLinkRequest addLinkRequest) { + log.info("LinkController addLink {}", Utils.sanitize(tgChatId)); + return linkService.addLink(tgChatId, addLinkRequest); + } + + @Operation(summary = "Убрать отслеживание ссылки") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Ссылка успешно убрана")}) + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/{tgChatId}") + public LinkResponse deleteLink( + @RequestHeader(value = "Tg-Chat-Id") Long tgChatId, + @RequestBody @Valid RemoveLinkRequest removeLinkRequest) { + log.info("LinkController deleteLink {}", Utils.sanitize(tgChatId)); + return linkService.deleteLink(tgChatId, removeLinkRequest.link()); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/controller/TagController.java b/scrapper/src/main/java/backend/academy/scrapper/controller/TagController.java new file mode 100644 index 0000000..21b1000 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/controller/TagController.java @@ -0,0 +1,49 @@ +package backend.academy.scrapper.controller; + +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 backend.academy.scrapper.util.Utils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@Slf4j +@RestController +@RequestMapping("/tag") +public class TagController { + + private final TagService tagService; + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{tgChatId}") + public ListLinksResponse getListLinksByTag( + @PathVariable("tgChatId") Long tgChatId, @RequestBody TagLinkRequest tagLinkRequest) { + log.error("Get links by tgChatId {} {}", Utils.sanitize(tgChatId), tagLinkRequest.toString()); + return tagService.getListLinkByTag(tgChatId, tagLinkRequest.tag()); + } + + @GetMapping("/{tgChatId}/all") + public TagListResponse getAllListLinksByTag(@PathVariable("tgChatId") Long tgChatId) { + log.info("getAllListLinksByTag: tgChatId={}", Utils.sanitize(tgChatId)); + return tagService.getAllListLinks(tgChatId); + } + + @DeleteMapping("/{tgChatId}") + public LinkResponse removeTagFromLink( + @PathVariable("tgChatId") Long tgChatId, @RequestBody TagRemoveRequest tagRemoveRequest) { + log.info("Remove tag link for tgChatId {} {}", Utils.sanitize(tgChatId), tagRemoveRequest.toString()); + return tagService.removeTagFromLink(tgChatId, tagRemoveRequest); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDao.java new file mode 100644 index 0000000..66378f2 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDao.java @@ -0,0 +1,10 @@ +package backend.academy.scrapper.dao; + +import java.util.List; + +public interface TgChatLinkDao { + + List getLinkIdsByChatId(Long chatId); + + void addRecord(Long chatId, Long linkId); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDaoImpl.java new file mode 100644 index 0000000..62a1029 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/TgChatLinkDaoImpl.java @@ -0,0 +1,29 @@ +package backend.academy.scrapper.dao; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@RequiredArgsConstructor +@Repository +public class TgChatLinkDaoImpl implements TgChatLinkDao { + + private final JdbcTemplate jdbcTemplate; + + private static final String GET_LINK_IDS_QUERY = "SELECT link_id FROM tg_chat_links WHERE tg_chat_id = ?"; + private static final String ADD_RECORD_QUERY = "INSERT INTO tg_chat_links (tg_chat_id, link_id) VALUES (?, ?)"; + + @Override + public List getLinkIdsByChatId(Long chatId) { + return jdbcTemplate.queryForList(GET_LINK_IDS_QUERY, Long.class, chatId); + } + + @Override + public void addRecord(Long chatId, Long linkId) { + log.info("Добавление записи в ChatLink: chatId={}, linkId={}", chatId, linkId); + jdbcTemplate.update(ADD_RECORD_QUERY, chatId, linkId); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDao.java new file mode 100644 index 0000000..9f79617 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDao.java @@ -0,0 +1,16 @@ +package backend.academy.scrapper.dao.accessfilter; + +import backend.academy.scrapper.dto.request.filter.FilterRequest; +import backend.academy.scrapper.dto.response.filter.FilterListResponse; +import backend.academy.scrapper.dto.response.filter.FilterResponse; + +public interface AccessFilterDao { + + boolean filterExists(String filter); + + FilterResponse createFilter(Long id, FilterRequest filterRequest); + + FilterListResponse getAllFilter(Long tgChatId); + + FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDaoImpl.java new file mode 100644 index 0000000..d2e1a3a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/accessfilter/AccessFilterDaoImpl.java @@ -0,0 +1,75 @@ +package backend.academy.scrapper.dao.accessfilter; + +import backend.academy.scrapper.dao.mapper.AccessFilterMapperDao; +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.entity.AccessFilter; +import backend.academy.scrapper.exception.filter.AccessFilterNotExistException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class AccessFilterDaoImpl implements AccessFilterDao { + + private final JdbcTemplate jdbcTemplate; + private final String ACCESS_FILTER_TABLE = "access_filter"; + + @Override + public boolean filterExists(String filter) { + String sql = "SELECT COUNT(*) FROM " + ACCESS_FILTER_TABLE + " WHERE filter = ?"; + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, filter); + return count != null && count > 0; + } + + @Override + public FilterResponse createFilter(Long id, FilterRequest filterRequest) { + String sql = "INSERT INTO " + ACCESS_FILTER_TABLE + " (tg_chat_id, filter) VALUES (?, ?) RETURNING id, filter"; + AccessFilter createdFilter = + jdbcTemplate.queryForObject(sql, new AccessFilterMapperDao(), id, filterRequest.filter()); + + if (createdFilter == null) { + throw new IllegalStateException("Ошибка создания фильтра"); + } + + return AccessFilterMapperDao.toResponse(createdFilter); + } + + @Override + public FilterListResponse getAllFilter(Long tgChatId) { + String sql = "SELECT id, filter FROM " + ACCESS_FILTER_TABLE + " WHERE tg_chat_id = ?"; + + List filters = jdbcTemplate.query(sql, new AccessFilterMapperDao(), tgChatId); + return new FilterListResponse( + filters.stream().map(AccessFilterMapperDao::toResponse).toList()); + } + + @Override + public FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest) { + String findSql = + "SELECT id, tg_chat_id, filter FROM " + ACCESS_FILTER_TABLE + " WHERE tg_chat_id = ? AND filter = ?"; + + List filters = + jdbcTemplate.query(findSql, new AccessFilterMapperDao(), tgChatId, filterRequest.filter()); + + if (filters.isEmpty()) { + throw new AccessFilterNotExistException("Filter not found for deletion"); + } + + Long filterId = filters.get(0).id(); + String deleteSql = "DELETE FROM " + ACCESS_FILTER_TABLE + " WHERE id = ? RETURNING *"; + + AccessFilter deletedFilter = jdbcTemplate.queryForObject(deleteSql, new AccessFilterMapperDao(), filterId); + + if (deletedFilter == null) { + throw new IllegalStateException("Failed to delete filter with id: " + filterId); + } + + return AccessFilterMapperDao.toResponse(deletedFilter); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDao.java new file mode 100644 index 0000000..c90fc53 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDao.java @@ -0,0 +1,9 @@ +package backend.academy.scrapper.dao.chat; + +public interface TgChatDao { + boolean isExistChat(Long id); + + void save(Long id); + + void remove(Long id); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDaoImpl.java new file mode 100644 index 0000000..2fc5bbe --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/chat/TgChatDaoImpl.java @@ -0,0 +1,44 @@ +package backend.academy.scrapper.dao.chat; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class TgChatDaoImpl implements TgChatDao { + + private final JdbcTemplate jdbcTemplate; + + private static final String EXISTS_QUERY = "SELECT 1 FROM tg_chats WHERE id = ? LIMIT 1"; + private static final String INSERT_QUERY = "INSERT INTO tg_chats VALUES (?, ?)"; + private static final String DELETE_QUERY = "DELETE FROM tg_chats WHERE id = ?"; + + @Transactional(readOnly = true) + @Override + public boolean isExistChat(Long id) { + try { + Integer result = jdbcTemplate.queryForObject(EXISTS_QUERY, Integer.class, id); + return result != null; + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + @Transactional + @Override + public void save(Long id) { + OffsetDateTime now = OffsetDateTime.now(ZoneId.systemDefault()); + jdbcTemplate.update(INSERT_QUERY, id, now); + } + + @Transactional + @Override + public void remove(Long id) { + jdbcTemplate.update(DELETE_QUERY, id); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDao.java new file mode 100644 index 0000000..3d87f65 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDao.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.dao.filter; + +import backend.academy.scrapper.entity.Filter; +import java.util.List; + +public interface FilterDao { + List findListFilterByLinkId(Long id); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDaoImpl.java new file mode 100644 index 0000000..6c24e91 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/filter/FilterDaoImpl.java @@ -0,0 +1,24 @@ +package backend.academy.scrapper.dao.filter; + +import backend.academy.scrapper.dao.mapper.FilterMapperDao; +import backend.academy.scrapper.entity.Filter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class FilterDaoImpl implements FilterDao { + + private final JdbcTemplate jdbcTemplate; + + private static final String FIND_FILTERS_QUERY = "SELECT id, filter, link_id FROM filters WHERE link_id = ?"; + + @Transactional(readOnly = true) + @Override + public List findListFilterByLinkId(Long id) { + return jdbcTemplate.query(FIND_FILTERS_QUERY, new Object[] {id}, new FilterMapperDao()); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDao.java new file mode 100644 index 0000000..7c49cff --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDao.java @@ -0,0 +1,22 @@ +package backend.academy.scrapper.dao.link; + +import backend.academy.scrapper.dto.request.AddLinkRequest; +import backend.academy.scrapper.entity.Link; +import java.util.List; +import java.util.Optional; + +public interface LinkDao { + List getListLinksByListLinkId(List ids); + + Long addLink(AddLinkRequest request); + + void remove(Long id); + + Optional findLinkByLinkId(Long id); + + List getAllLinks(int offset, int limit); + + List findAllLinksByChatIdWithFilter(int offset, int limit); + + void update(Link link); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDaoImpl.java new file mode 100644 index 0000000..7e88d21 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/link/LinkDaoImpl.java @@ -0,0 +1,220 @@ +package backend.academy.scrapper.dao.link; + +import backend.academy.scrapper.dao.mapper.FilterMapperDao; +import backend.academy.scrapper.dao.mapper.LinkMapperDao; +import backend.academy.scrapper.dao.mapper.TagMapperDao; +import backend.academy.scrapper.dto.request.AddLinkRequest; +import backend.academy.scrapper.entity.Filter; +import backend.academy.scrapper.entity.Link; +import backend.academy.scrapper.entity.Tag; +import backend.academy.scrapper.exception.chat.ChatNotExistException; +import backend.academy.scrapper.exception.link.LinkNotFoundException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Repository +public class LinkDaoImpl implements LinkDao { + + private final JdbcTemplate jdbcTemplate; + private static final String TABLE_LINKS = "links"; + private static final String TABLE_FILTERS = "filters"; + private static final String TABLE_TAGS = "tags"; + private static final String TABLE_ACCESS_FILTERS = "access_filter"; + + @Transactional(readOnly = true) + @Override + public List getListLinksByListLinkId(List ids) { + + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + + NamedParameterJdbcTemplate namedTemplate = new NamedParameterJdbcTemplate(jdbcTemplate); + MapSqlParameterSource parameters = new MapSqlParameterSource(); + parameters.addValue("ids", ids); + + String linksSql = "SELECT id, url, description, updated_at FROM " + TABLE_LINKS + " WHERE id IN (:ids)"; + List links = namedTemplate.query(linksSql, parameters, new LinkMapperDao()); + + Set foundIds = links.stream().map(Link::id).collect(Collectors.toSet()); + for (Long id : ids) { + if (!foundIds.contains(id)) { + throw new LinkNotFoundException("Такой ссылки нет: " + id); + } + } + + String allTagsSql = "SELECT link_id, id, tag FROM " + TABLE_TAGS + " WHERE link_id IN (:ids)"; + List> allTags = namedTemplate.queryForList(allTagsSql, parameters); + + String allFiltersSql = "SELECT link_id, id, filter FROM " + TABLE_FILTERS + " WHERE link_id IN (:ids)"; + List> allFilters = namedTemplate.queryForList(allFiltersSql, parameters); + + Map> tagsByLinkId = new HashMap<>(); + Map> filtersByLinkId = new HashMap<>(); + + for (Map tagRow : allTags) { + Long linkId = (Long) tagRow.get("link_id"); + Long tagId = (Long) tagRow.get("id"); + String tagName = (String) tagRow.get("tag"); + + Tag tag = Tag.create(tagId, tagName); + tagsByLinkId.computeIfAbsent(linkId, k -> new ArrayList<>()).add(tag); + } + + for (Map filterRow : allFilters) { + Long linkId = (Long) filterRow.get("link_id"); + Long filterId = (Long) filterRow.get("id"); + String filterName = (String) filterRow.get("filter"); + + Filter filter = Filter.create(filterId, filterName); + filtersByLinkId.computeIfAbsent(linkId, k -> new ArrayList<>()).add(filter); + } + + for (Link link : links) { + Long linkId = link.id(); + link.tags(tagsByLinkId.getOrDefault(linkId, Collections.emptyList())); + link.filters(filtersByLinkId.getOrDefault(linkId, Collections.emptyList())); + } + + return links; + } + + @Transactional + @Override + public Long addLink(AddLinkRequest request) { + log.debug("Начало добавления ссылки: {}", request.link()); + // Вставка ссылки с одновременным получением ID + Long linkId = jdbcTemplate.queryForObject( + "INSERT INTO " + TABLE_LINKS + " (url, description, updated_at) VALUES (?, ?, ?) RETURNING id", + Long.class, + request.link().toString(), + null, + null); + + if (linkId == null) { + throw new ChatNotExistException("Не удалось получить ID вставленной записи"); + } + + // Вставка тегов + if (request.tags() != null && !request.tags().isEmpty()) { + String insertTagSql = "INSERT INTO " + TABLE_TAGS + " (link_id, tag) VALUES (?, ?)"; + for (String tag : request.tags()) { + jdbcTemplate.update(insertTagSql, linkId, tag); + } + log.info("Теги вставлены в таблицу tags для ссылки с chatId = {}", linkId); + } + + // Вставка фильтров + if (request.filters() != null && !request.filters().isEmpty()) { + String insertFilterSql = "INSERT INTO " + TABLE_FILTERS + " (link_id, filter) VALUES (?, ?)"; + for (String filter : request.filters()) { + jdbcTemplate.update(insertFilterSql, linkId, filter); + } + log.info("Фильтры вставлены в таблицу filters для ссылки с chatId = {}", linkId); + } + + return linkId; + } + + @Transactional + @Override + public void remove(Long id) { + log.info("Удаление записи из таблицы {} с ID: {}", TABLE_LINKS, id); + String sql = "DELETE FROM " + TABLE_LINKS + " WHERE id = ?"; + jdbcTemplate.update(sql, id); + } + + @Transactional(readOnly = true) + @Override + public Optional findLinkByLinkId(Long id) { + // Запрос для получения данных о ссылке + String linkSql = "SELECT id, url, description, updated_at FROM " + TABLE_LINKS + " WHERE id = ?"; + + Optional linkOptional = + jdbcTemplate.query(linkSql, new LinkMapperDao(), id).stream().findFirst(); + + if (linkOptional.isEmpty()) { + return Optional.empty(); + } + + Link link = linkOptional.orElseThrow(() -> new LinkNotFoundException("Link not found")); + + String tagsSql = "SELECT id, tag FROM " + TABLE_TAGS + " WHERE link_id = ?"; + List tags = jdbcTemplate.query(tagsSql, new TagMapperDao(), id); + link.tags(tags); + + String filtersSql = "SELECT id, filter FROM " + TABLE_FILTERS + " WHERE link_id = ?"; + List filters = jdbcTemplate.query(filtersSql, new FilterMapperDao(), id); + link.filters(filters); + + return Optional.of(link); + } + + @Transactional(readOnly = true) + @Override + public List getAllLinks(int offset, int limit) { + // Запрос для получения данных о ссылках + String linksSql = "SELECT id, url, description, updated_at FROM links LIMIT ? OFFSET ?"; + + List links = jdbcTemplate.query(linksSql, new Object[] {limit, offset}, new LinkMapperDao()); + + // Для каждой ссылки получаем теги и фильтры + for (Link link : links) { + Long linkId = link.id(); + + String tagsSql = "SELECT id, tag FROM tags WHERE link_id = ?"; + List tags = jdbcTemplate.query(tagsSql, new TagMapperDao(), linkId); + link.tags(tags); + + String filtersSql = "SELECT id, filter FROM filters WHERE link_id = ?"; + List filters = jdbcTemplate.query(filtersSql, new FilterMapperDao(), linkId); + link.filters(filters); + } + + return links; + } + + @Transactional(readOnly = true) + @Override + public List findAllLinksByChatIdWithFilter(int offset, int limit) { + String query = + """ + SELECT + l.id, + l.url, + l.description, + l.updated_at + FROM + links l + JOIN filters f ON (l.id = f.link_id) + WHERE f.filter NOT IN (SELECT DISTINCT access_filter.filter FROM access_filter) + LIMIT ? OFFSET ? + """; + return jdbcTemplate.query(query, new Object[] {limit, offset}, new LinkMapperDao()); + } + + @Transactional + @Override + public void update(Link link) { + Optional optionalLink = findLinkByLinkId(link.id()); + if (optionalLink.isPresent()) { + String query = "UPDATE " + TABLE_LINKS + " SET description = ?, updated_at = ? WHERE id = ?"; + jdbcTemplate.update(query, link.description(), link.updatedAt(), link.id()); + } + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/AccessFilterMapperDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/AccessFilterMapperDao.java new file mode 100644 index 0000000..5464958 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/AccessFilterMapperDao.java @@ -0,0 +1,21 @@ +package backend.academy.scrapper.dao.mapper; + +import backend.academy.scrapper.dto.response.filter.FilterResponse; +import backend.academy.scrapper.entity.AccessFilter; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.springframework.jdbc.core.RowMapper; + +public class AccessFilterMapperDao implements RowMapper { + @Override + public AccessFilter mapRow(ResultSet rs, int rowNum) throws SQLException { + return AccessFilter.builder() + .id(rs.getLong("id")) + .filter(rs.getString("filter")) + .build(); + } + + public static FilterResponse toResponse(AccessFilter accessFilter) { + return new FilterResponse(accessFilter.id(), accessFilter.filter()); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/FilterMapperDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/FilterMapperDao.java new file mode 100644 index 0000000..c575e06 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/FilterMapperDao.java @@ -0,0 +1,16 @@ +package backend.academy.scrapper.dao.mapper; + +import backend.academy.scrapper.entity.Filter; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.springframework.jdbc.core.RowMapper; + +public class FilterMapperDao implements RowMapper { + @Override + public Filter mapRow(ResultSet rs, int rowNum) throws SQLException { + Filter filter = new Filter(); + filter.id(rs.getLong("id")); + filter.filter(rs.getString("filter")); + return filter; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/LinkMapperDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/LinkMapperDao.java new file mode 100644 index 0000000..359a08b --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/LinkMapperDao.java @@ -0,0 +1,26 @@ +package backend.academy.scrapper.dao.mapper; + +import backend.academy.scrapper.entity.Link; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.springframework.jdbc.core.RowMapper; + +public class LinkMapperDao implements RowMapper { + + @Override + public Link mapRow(ResultSet rs, int rowNum) throws SQLException { + return Link.builder() + .id(rs.getLong("id")) + .url(rs.getString("url")) + .description(rs.getString("description")) + .updatedAt(mapToOffsetDateTime(rs.getTimestamp("updated_at"))) + .build(); + } + + private OffsetDateTime mapToOffsetDateTime(Timestamp timestamp) { + return timestamp != null ? timestamp.toInstant().atOffset(ZoneOffset.UTC) : null; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/TagMapperDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/TagMapperDao.java new file mode 100644 index 0000000..6dc8650 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/mapper/TagMapperDao.java @@ -0,0 +1,17 @@ +package backend.academy.scrapper.dao.mapper; + +import backend.academy.scrapper.entity.Tag; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.springframework.jdbc.core.RowMapper; + +public class TagMapperDao implements RowMapper { + + @Override + public Tag mapRow(ResultSet rs, int rowNum) throws SQLException { + Tag tag = new Tag(); + tag.id(rs.getLong("id")); + tag.tag(rs.getString("tag")); + return tag; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDao.java b/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDao.java new file mode 100644 index 0000000..88643c7 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDao.java @@ -0,0 +1,11 @@ +package backend.academy.scrapper.dao.tag; + +import backend.academy.scrapper.entity.Tag; +import java.util.List; + +public interface TagDao { + + List findListTagByLinkId(Long id); + + void removeTag(Long id, String removedTag); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDaoImpl.java b/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDaoImpl.java new file mode 100644 index 0000000..9ccdee0 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dao/tag/TagDaoImpl.java @@ -0,0 +1,32 @@ +package backend.academy.scrapper.dao.tag; + +import backend.academy.scrapper.dao.mapper.TagMapperDao; +import backend.academy.scrapper.entity.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class TagDaoImpl implements TagDao { + + private final JdbcTemplate jdbcTemplate; + + private static final String TABLE_TAGS = "tags"; + + @Transactional(readOnly = true) + @Override + public List findListTagByLinkId(Long id) { + String query = "SELECT id, tag, link_id FROM " + TABLE_TAGS + " WHERE link_id = ?"; + return jdbcTemplate.query(query, new Object[] {id}, new TagMapperDao()); + } + + @Transactional + @Override + public void removeTag(Long id, String removedTag) { + String query = "DELETE FROM " + TABLE_TAGS + " WHERE link_id = ? AND tag = ?"; + jdbcTemplate.update(query, new Object[] {id, removedTag}); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/request/AddLinkRequest.java b/scrapper/src/main/java/backend/academy/scrapper/dto/request/AddLinkRequest.java new file mode 100644 index 0000000..d86552c --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/request/AddLinkRequest.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.util.List; + +public record AddLinkRequest( + @NotNull(message = "URL не может быть пустым") URI link, List tags, List filters) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/request/RemoveLinkRequest.java b/scrapper/src/main/java/backend/academy/scrapper/dto/request/RemoveLinkRequest.java new file mode 100644 index 0000000..a7dde4a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/request/RemoveLinkRequest.java @@ -0,0 +1,6 @@ +package backend.academy.scrapper.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.net.URI; + +public record RemoveLinkRequest(@NotNull(message = "URL не может быть пустым") URI link) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/request/filter/FilterRequest.java b/scrapper/src/main/java/backend/academy/scrapper/dto/request/filter/FilterRequest.java new file mode 100644 index 0000000..7d61d04 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/request/filter/FilterRequest.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.dto.request.filter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FilterRequest( + @NotBlank @Size(max = 50, message = "Длина фильтра не должна превышать 50 символов") String filter) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagLinkRequest.java b/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagLinkRequest.java new file mode 100644 index 0000000..7add338 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagLinkRequest.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.dto.request.tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TagLinkRequest( + @NotBlank @Size(max = 50, message = "Длина тега не должна превышать 50 символов") String tag) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagRemoveRequest.java b/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagRemoveRequest.java new file mode 100644 index 0000000..be0b297 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/request/tag/TagRemoveRequest.java @@ -0,0 +1,10 @@ +package backend.academy.scrapper.dto.request.tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.net.URI; + +public record TagRemoveRequest( + @NotBlank @Size(max = 50, message = "Длина тега не должна превышать 50 символов") String tag, + @NotNull(message = "URL не может быть пустым") URI uri) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/ApiErrorResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/ApiErrorResponse.java new file mode 100644 index 0000000..7b2f832 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/ApiErrorResponse.java @@ -0,0 +1,11 @@ +package backend.academy.scrapper.dto.response; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public record ApiErrorResponse( + @NotBlank(message = "description не может быть пустым") String description, + @NotBlank(message = "code не может быть пустым") String code, + String exceptionName, + String exceptionMessage, + List stacktrace) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/LinkResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/LinkResponse.java new file mode 100644 index 0000000..9284c6c --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/LinkResponse.java @@ -0,0 +1,6 @@ +package backend.academy.scrapper.dto.response; + +import java.net.URI; +import java.util.List; + +public record LinkResponse(Long id, URI url, List tags, List filters) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/ListLinksResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/ListLinksResponse.java new file mode 100644 index 0000000..e57a553 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/ListLinksResponse.java @@ -0,0 +1,5 @@ +package backend.academy.scrapper.dto.response; + +import java.util.List; + +public record ListLinksResponse(List links, Integer size) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/TagListResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/TagListResponse.java new file mode 100644 index 0000000..a20bb95 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/TagListResponse.java @@ -0,0 +1,5 @@ +package backend.academy.scrapper.dto.response; + +import java.util.List; + +public record TagListResponse(List tags) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterListResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterListResponse.java new file mode 100644 index 0000000..805b0c5 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterListResponse.java @@ -0,0 +1,5 @@ +package backend.academy.scrapper.dto.response.filter; + +import java.util.List; + +public record FilterListResponse(List filterList) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterResponse.java b/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterResponse.java new file mode 100644 index 0000000..d1d27cd --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/dto/response/filter/FilterResponse.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.dto.response.filter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FilterResponse( + Long id, @NotBlank @Size(max = 50, message = "Длина фильтра не должна превышать 50 символов") String filter) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/AccessFilter.java b/scrapper/src/main/java/backend/academy/scrapper/entity/AccessFilter.java new file mode 100644 index 0000000..c7a8394 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/AccessFilter.java @@ -0,0 +1,45 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@ToString +@Setter +@Entity +@Table(name = "access_filter") +public class AccessFilter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "tg_chat_id", nullable = false) + private TgChat tgChat; + + @Column(name = "filter", nullable = false) + private String filter; + + public static AccessFilter create(TgChat tgChat, String filter) { + AccessFilter accessFilter = new AccessFilter(); + accessFilter.tgChat = tgChat; + accessFilter.filter = filter; + return accessFilter; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/Filter.java b/scrapper/src/main/java/backend/academy/scrapper/entity/Filter.java new file mode 100644 index 0000000..191ea8b --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/Filter.java @@ -0,0 +1,52 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@ToString +@Setter +@Entity +@Table(name = "filters") +public class Filter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne + @JoinColumn(name = "link_id", nullable = false) + private Link link; + + @Column(name = "filter") + private String filter; + + // Фабричный метод + public static Filter create(String filterValue, Link link) { + Filter filter = new Filter(); + filter.filter(filterValue); + filter.link(link); + return filter; + } + + public static Filter create(Long id, String filterValue) { + Filter filter = new Filter(); + filter.id(id); + filter.filter(filterValue); + return filter; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/Link.java b/scrapper/src/main/java/backend/academy/scrapper/entity/Link.java new file mode 100644 index 0000000..6ce35a0 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/Link.java @@ -0,0 +1,63 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Builder +@Entity +@Table(name = "links") +public class Link { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "url", nullable = false) + private String url; + + @Column(name = "description") + private String description; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + // ---------------------- + + @ToString.Exclude + @OneToMany(mappedBy = "link", fetch = FetchType.LAZY) + @Builder.Default + private List tgChatLinks = new ArrayList<>(); + + @ToString.Exclude + @OneToMany(mappedBy = "link", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List filters = new ArrayList<>(); + + @ToString.Exclude + @OneToMany(mappedBy = "link", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private List tags = new ArrayList<>(); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/Tag.java b/scrapper/src/main/java/backend/academy/scrapper/entity/Tag.java new file mode 100644 index 0000000..436e8d3 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/Tag.java @@ -0,0 +1,52 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@ToString +@Table(name = "tags") +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "link_id", nullable = false, referencedColumnName = "id") + private Link link; + + @Column(name = "tag", nullable = false) + private String tag; + + // Фабричный метод + public static Tag create(String tagName, Link link) { + Tag tag = new Tag(); + tag.tag(tagName); + tag.link(link); + return tag; + } + + // Фабричный метод + public static Tag create(Long id, String tagName) { + Tag tag = new Tag(); + tag.id = id; + tag.tag(tagName); + return tag; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/TgChat.java b/scrapper/src/main/java/backend/academy/scrapper/entity/TgChat.java new file mode 100644 index 0000000..cff6325 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/TgChat.java @@ -0,0 +1,40 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@AllArgsConstructor +@Getter +@Setter +@ToString +@Entity +@Table(name = "tg_chats") +@Builder +@NoArgsConstructor +public class TgChat { + @Id + @Column(name = "id") + private Long id; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @OneToMany(mappedBy = "tgChat", cascade = CascadeType.ALL, orphanRemoval = true) + private List tgChatLinks = new ArrayList<>(); // Явная инициализация; + + @OneToMany(mappedBy = "tgChat", cascade = CascadeType.ALL, orphanRemoval = true) + private List accessFilters = new ArrayList<>(); // Явная инициализация; +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/entity/TgChatLink.java b/scrapper/src/main/java/backend/academy/scrapper/entity/TgChatLink.java new file mode 100644 index 0000000..68cfc3a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/entity/TgChatLink.java @@ -0,0 +1,50 @@ +package backend.academy.scrapper.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "tg_chat_linkS") +public class TgChatLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "tg_chat_id", nullable = false) + private TgChat tgChat; + + @ManyToOne + @JoinColumn(name = "link_id", nullable = false) + private Link link; + + public void setChat(TgChat tgChat) { + this.tgChat = tgChat; + if (tgChat != null) { + tgChat.tgChatLinks().add(this); + } + } + + public void setLink(Link link) { + this.link = link; + if (link != null) { + link.tgChatLinks().add(this); + } + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatAlreadyExistsException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatAlreadyExistsException.java new file mode 100644 index 0000000..bc441f8 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatAlreadyExistsException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.chat; + +public class ChatAlreadyExistsException extends RuntimeException { + public ChatAlreadyExistsException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatIllegalArgumentException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatIllegalArgumentException.java new file mode 100644 index 0000000..40911d0 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatIllegalArgumentException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.chat; + +public class ChatIllegalArgumentException extends RuntimeException { + public ChatIllegalArgumentException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatNotExistException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatNotExistException.java new file mode 100644 index 0000000..515fadd --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/chat/ChatNotExistException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.chat; + +public class ChatNotExistException extends RuntimeException { + public ChatNotExistException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterAlreadyExistException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterAlreadyExistException.java new file mode 100644 index 0000000..f29c1bb --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterAlreadyExistException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.filter; + +public class AccessFilterAlreadyExistException extends RuntimeException { + public AccessFilterAlreadyExistException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterNotExistException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterNotExistException.java new file mode 100644 index 0000000..b5faf8a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/filter/AccessFilterNotExistException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.filter; + +public class AccessFilterNotExistException extends RuntimeException { + public AccessFilterNotExistException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/handler/AccessFilterExceptionHandler.java b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/AccessFilterExceptionHandler.java new file mode 100644 index 0000000..2a11437 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/AccessFilterExceptionHandler.java @@ -0,0 +1,40 @@ +package backend.academy.scrapper.exception.handler; + +import backend.academy.scrapper.dto.response.ApiErrorResponse; +import backend.academy.scrapper.exception.filter.AccessFilterAlreadyExistException; +import backend.academy.scrapper.exception.filter.AccessFilterNotExistException; +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 AccessFilterExceptionHandler { + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Такой фильтр уже существует")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(AccessFilterAlreadyExistException.class) + public ApiErrorResponse handlerException(AccessFilterAlreadyExistException ex) { + log.error("AccessFilterAlreadyExistException: {}", ex.getMessage()); + return new ApiErrorResponse( + "Такой фильтр уже существует", + "BAD_REQUEST", + ex.getClass().getName(), + ex.getMessage(), + Utils.getStackTrace(ex)); + } + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Такого фильтра нет")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(AccessFilterNotExistException.class) + public ApiErrorResponse handlerException(AccessFilterNotExistException ex) { + log.error("AccessFilterNotExistException: {}", 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/exception/handler/ChatExceptionHandler.java b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/ChatExceptionHandler.java new file mode 100644 index 0000000..e556af6 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/ChatExceptionHandler.java @@ -0,0 +1,58 @@ +package backend.academy.scrapper.exception.handler; + +import backend.academy.scrapper.dto.response.ApiErrorResponse; +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.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 ChatExceptionHandler { + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(ChatNotExistException.class) + public ApiErrorResponse handlerException(ChatNotExistException ex) { + log.error("ChatNotExistException: {}", ex.getMessage()); + return new ApiErrorResponse( + "Некорректные параметры запроса", + "BAD_REQUEST", + ex.getClass().getName(), + ex.getMessage(), + Utils.getStackTrace(ex)); + } + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(ChatIllegalArgumentException.class) + public ApiErrorResponse handlerException(ChatIllegalArgumentException ex) { + log.error("ChatIllegalArgumentException: {}", ex.getMessage()); + return new ApiErrorResponse( + "Некорректные параметры запроса", + "BAD_REQUEST", + ex.getClass().getName(), + ex.getMessage(), + Utils.getStackTrace(ex)); + } + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(ChatAlreadyExistsException.class) + public ApiErrorResponse handlerException(ChatAlreadyExistsException ex) { + log.error("ChatAlreadyExistsException: {}", 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/exception/handler/LinkExceptionHandler.java b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/LinkExceptionHandler.java new file mode 100644 index 0000000..8bbefbe --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/LinkExceptionHandler.java @@ -0,0 +1,40 @@ +package backend.academy.scrapper.exception.handler; + +import backend.academy.scrapper.dto.response.ApiErrorResponse; +import backend.academy.scrapper.exception.link.LinkAlreadyExistException; +import backend.academy.scrapper.exception.link.LinkNotFoundException; +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 LinkExceptionHandler { + + @ApiResponses(value = {@ApiResponse(responseCode = "404", description = "Ссылка не найдена")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(LinkNotFoundException.class) + public ApiErrorResponse handlerException(LinkNotFoundException ex) { + log.error("LinkNotFoundException: {}", ex.getMessage()); + return new ApiErrorResponse( + "Ссылка не найдена", "BAD_REQUEST", ex.getClass().getName(), ex.getMessage(), Utils.getStackTrace(ex)); + } + + @ApiResponses(value = {@ApiResponse(responseCode = "400", description = "Некорректные параметры запроса")}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(LinkAlreadyExistException.class) + public ApiErrorResponse handlerException(LinkAlreadyExistException ex) { + log.error("LinkAlreadyExistException: {}", 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/exception/handler/TagExceptionHandler.java b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/TagExceptionHandler.java new file mode 100644 index 0000000..a0e5a2a --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/handler/TagExceptionHandler.java @@ -0,0 +1,23 @@ +package backend.academy.scrapper.exception.handler; + +import backend.academy.scrapper.dto.response.ApiErrorResponse; +import backend.academy.scrapper.exception.tag.TagNotExistException; +import backend.academy.scrapper.util.Utils; +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 TagExceptionHandler { + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(TagNotExistException.class) + public ApiErrorResponse handlerException(TagNotExistException ex) { + log.error("TagNotExistException: {}", ex.getMessage()); + return new ApiErrorResponse( + "Тег не найден", "NOT_FOUND", ex.getClass().getName(), ex.getMessage(), Utils.getStackTrace(ex)); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkAlreadyExistException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkAlreadyExistException.java new file mode 100644 index 0000000..0bd4c20 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkAlreadyExistException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.link; + +public class LinkAlreadyExistException extends RuntimeException { + public LinkAlreadyExistException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkNotFoundException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkNotFoundException.java new file mode 100644 index 0000000..eb96147 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/link/LinkNotFoundException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.link; + +public class LinkNotFoundException extends RuntimeException { + public LinkNotFoundException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/exception/tag/TagNotExistException.java b/scrapper/src/main/java/backend/academy/scrapper/exception/tag/TagNotExistException.java new file mode 100644 index 0000000..aae47a2 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/exception/tag/TagNotExistException.java @@ -0,0 +1,7 @@ +package backend.academy.scrapper.exception.tag; + +public class TagNotExistException extends RuntimeException { + public TagNotExistException(String message) { + super(message); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitConfig.java b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitConfig.java new file mode 100644 index 0000000..2282ea2 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitConfig.java @@ -0,0 +1,30 @@ +package backend.academy.scrapper.limit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RateLimitConfig { + + private final RateLimitProperties properties; + + @Bean + public Map ipBuckets() { + return new ConcurrentHashMap<>(); + } + + @Bean + public Bandwidth bandwidth() { + return Bandwidth.classic( + properties.capacity(), + Refill.intervally(properties.refillAmount(), Duration.ofSeconds(properties.refillSeconds()))); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitInterceptor.java b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitInterceptor.java new file mode 100644 index 0000000..45e74ef --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitInterceptor.java @@ -0,0 +1,26 @@ +package backend.academy.scrapper.limit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class RateLimitInterceptor implements HandlerInterceptor { + + private final RateLimitService rateLimitService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String clientIp = request.getRemoteAddr(); + if (!rateLimitService.tryConsume(clientIp)) { + response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "Rate limit exceeded"); + return false; + } + return true; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitProperties.java b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitProperties.java new file mode 100644 index 0000000..4ae3d88 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitProperties.java @@ -0,0 +1,6 @@ +package backend.academy.scrapper.limit; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "bucket4j.rate.limit") +public record RateLimitProperties(int capacity, int refillAmount, int refillSeconds) {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitService.java b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitService.java new file mode 100644 index 0000000..fb86f05 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/limit/RateLimitService.java @@ -0,0 +1,23 @@ +package backend.academy.scrapper.limit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RateLimitService { + + // IP - ключ + private final Map ipBuckets; + private final Bandwidth bandwidth; + + public boolean tryConsume(String clientIp) { + Bucket bucket = ipBuckets.computeIfAbsent( + clientIp, k -> Bucket.builder().addLimit(bandwidth).build()); + + return bucket.tryConsume(1); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/limit/WebConfig.java b/scrapper/src/main/java/backend/academy/scrapper/limit/WebConfig.java new file mode 100644 index 0000000..02e9fc1 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/limit/WebConfig.java @@ -0,0 +1,18 @@ +package backend.academy.scrapper.limit; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**"); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/mapper/FilterMapper.java b/scrapper/src/main/java/backend/academy/scrapper/mapper/FilterMapper.java new file mode 100644 index 0000000..3356ebc --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/mapper/FilterMapper.java @@ -0,0 +1,30 @@ +package backend.academy.scrapper.mapper; + +import backend.academy.scrapper.dto.response.filter.FilterResponse; +import backend.academy.scrapper.entity.AccessFilter; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class FilterMapper { + + public FilterResponse toFilterResponse(AccessFilter accessFilter) { + if (accessFilter == null) { + return null; + } + return new FilterResponse(accessFilter.id(), accessFilter.filter()); + } + + public List toFilterResponseList(List accessFilters) { + if (accessFilters == null) { + return Collections.emptyList(); + } + return accessFilters.stream() + .map(this::toFilterResponse) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/mapper/LinkMapper.java b/scrapper/src/main/java/backend/academy/scrapper/mapper/LinkMapper.java new file mode 100644 index 0000000..e861a60 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/mapper/LinkMapper.java @@ -0,0 +1,39 @@ +package backend.academy.scrapper.mapper; + +import backend.academy.scrapper.dto.response.LinkResponse; +import backend.academy.scrapper.entity.Filter; +import backend.academy.scrapper.entity.Link; +import backend.academy.scrapper.entity.Tag; +import backend.academy.scrapper.tracker.update.dto.LinkDto; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class LinkMapper { + + public LinkResponse linkToLinkResponse(Link link) { + List tags = link.tags().stream().map(Tag::tag).toList(); + List filters = link.filters().stream().map(Filter::filter).toList(); + return new LinkResponse(link.id(), URI.create(link.url()), tags, filters); + } + + public List linkListToLinkResponseList(List linkList) { + List list = new ArrayList<>(); + for (Link link : linkList) { + list.add(linkToLinkResponse(link)); + } + return list; + } + + public List listLinkToListLinkDto(List list) { + List linkDtoList = new ArrayList<>(); + for (Link link : list) { + LinkDto linkDto = + new LinkDto(link.id(), URI.create(link.url().trim()), link.updatedAt(), link.description()); + linkDtoList.add(linkDto); + } + return linkDtoList; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/AccessFilterRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/AccessFilterRepository.java new file mode 100644 index 0000000..5f9baf2 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/AccessFilterRepository.java @@ -0,0 +1,14 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.AccessFilter; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AccessFilterRepository extends JpaRepository { + + boolean existsAccessFilterByFilter( + @NotBlank @Size(max = 50, message = "Длина фильтра не должна превышать 50 символов") String filter); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/FilterRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/FilterRepository.java new file mode 100644 index 0000000..232a931 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/FilterRepository.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.Filter; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FilterRepository extends JpaRepository {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/LinkRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/LinkRepository.java new file mode 100644 index 0000000..02c90f2 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/LinkRepository.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.Link; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LinkRepository extends JpaRepository {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/TagRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/TagRepository.java new file mode 100644 index 0000000..5570702 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/TagRepository.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.Tag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TagRepository extends JpaRepository {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatLinkRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatLinkRepository.java new file mode 100644 index 0000000..ebbc4ee --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatLinkRepository.java @@ -0,0 +1,27 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.Link; +import backend.academy.scrapper.entity.TgChatLink; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TgChatLinkRepository extends JpaRepository { + + @Query("SELECT cl.link FROM TgChatLink cl WHERE cl.tgChat.id = :chatId") + List findLinksByChatId(@Param("chatId") Long chatId); + + @Query("SELECT cl FROM TgChatLink cl " + "JOIN cl.link l " + "WHERE cl.tgChat.id = :chatId AND l.url = :url") + Optional findByChatIdAndLinkUrl(@Param("chatId") Long chatId, @Param("url") String url); + + @Query("SELECT COUNT(cl) FROM TgChatLink cl WHERE cl.link.id = :linkId") + long countByLinkId(@Param("linkId") Long linkId); + + // Метод для получения списка chatId чатов по chatId ссылки + @Query("SELECT cl.tgChat.id FROM TgChatLink cl WHERE cl.link.id = :linkId") + List findChatIdsByLinkId(@Param("linkId") Long linkId); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatRepository.java b/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatRepository.java new file mode 100644 index 0000000..26935ce --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/repository/TgChatRepository.java @@ -0,0 +1,8 @@ +package backend.academy.scrapper.repository; + +import backend.academy.scrapper.entity.TgChat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TgChatRepository extends JpaRepository {} diff --git a/scrapper/src/main/java/backend/academy/scrapper/scheduler/LinkUpdaterScheduler.java b/scrapper/src/main/java/backend/academy/scrapper/scheduler/LinkUpdaterScheduler.java new file mode 100644 index 0000000..852c5c7 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/scheduler/LinkUpdaterScheduler.java @@ -0,0 +1,70 @@ +package backend.academy.scrapper.scheduler; + +import backend.academy.scrapper.entity.Link; +import backend.academy.scrapper.mapper.LinkMapper; +import backend.academy.scrapper.service.LinkService; +import backend.academy.scrapper.tracker.update.LinkUpdateProcessor; +import backend.academy.scrapper.tracker.update.dto.LinkDto; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LinkUpdaterScheduler { + + private final LinkUpdateProcessor linkUpdateProcessor; + private final LinkMapper linksMapper; + private final LinkService linkService; + private final ExecutorService executorService = Executors.newFixedThreadPool(4); + private static final int COUNT_THREAD = 4; + + @Value("${scheduler.batch-size}") + private int batchSize; + + @Scheduled(fixedDelayString = "${scheduler.interval}") + public void update() { + log.info("Проверка обновления"); + + int offset = 0; + List links; + + do { + // Получаем батч линков + links = linkService.findAllLinksByChatIdWithFilter(offset, batchSize); + + List linkDtoList = linksMapper.listLinkToListLinkDto(links); + List> batches = splitIntoBatches(linkDtoList, COUNT_THREAD); + + List> futures = batches.stream() + .map(batch -> + CompletableFuture.runAsync(() -> linkUpdateProcessor.updateLink(batch), executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + log.info("Ссылки на обновления: {}", linkDtoList); + + linkUpdateProcessor.updateLink(linkDtoList); + offset += batchSize; + } while (!links.isEmpty()); + } + + private List> splitIntoBatches(List linkList, int countTread) { + int batchSize = (linkList.size() + countTread - 1) / countTread; + List> batches = new ArrayList<>(); + + for (int i = 0; i < linkList.size(); i += batchSize) { + batches.add(linkList.subList(i, Math.min(i + batchSize, linkList.size()))); + } + return batches; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/AccessFilterService.java b/scrapper/src/main/java/backend/academy/scrapper/service/AccessFilterService.java new file mode 100644 index 0000000..5b5b5a3 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/AccessFilterService.java @@ -0,0 +1,14 @@ +package backend.academy.scrapper.service; + +import backend.academy.scrapper.dto.request.filter.FilterRequest; +import backend.academy.scrapper.dto.response.filter.FilterListResponse; +import backend.academy.scrapper.dto.response.filter.FilterResponse; + +public interface AccessFilterService { + + FilterResponse createFilter(Long id, FilterRequest filterRequest); + + FilterListResponse getAllFilter(Long tgChatId); + + FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/ChatService.java b/scrapper/src/main/java/backend/academy/scrapper/service/ChatService.java new file mode 100644 index 0000000..701e39e --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/ChatService.java @@ -0,0 +1,19 @@ +package backend.academy.scrapper.service; + +import backend.academy.scrapper.entity.TgChat; +import backend.academy.scrapper.exception.chat.ChatIllegalArgumentException; +import java.util.Optional; + +public interface ChatService { + void registerChat(Long id); + + void deleteChat(Long id); + + Optional findChatById(Long id); + + default void checkIsCorrect(Long id) { + if (id == null || id < 1) { + throw new ChatIllegalArgumentException("Chat-chatId должно быть положительное, chatId = " + id); + } + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/LinkService.java b/scrapper/src/main/java/backend/academy/scrapper/service/LinkService.java new file mode 100644 index 0000000..c8fcf52 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/LinkService.java @@ -0,0 +1,24 @@ +package backend.academy.scrapper.service; + +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 java.net.URI; +import java.util.List; +import java.util.Optional; + +public interface LinkService { + + ListLinksResponse findAllLinksByChatId(Long tgChatId); + + LinkResponse addLink(Long tgChatId, AddLinkRequest request); + + LinkResponse deleteLink(Long tgChatId, URI uri); + + Optional findById(Long id); + + List findAllLinksByChatIdWithFilter(int offset, int limit); + + void update(Link link); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/TagService.java b/scrapper/src/main/java/backend/academy/scrapper/service/TagService.java new file mode 100644 index 0000000..9caf521 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/TagService.java @@ -0,0 +1,14 @@ +package backend.academy.scrapper.service; + +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; + +public interface TagService { + ListLinksResponse getListLinkByTag(Long tgChatId, String tag); + + TagListResponse getAllListLinks(Long tgChatId); + + LinkResponse removeTagFromLink(Long tgChatId, TagRemoveRequest tagRemoveRequest); +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcAccessFilterService.java b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcAccessFilterService.java new file mode 100644 index 0000000..eb3fd11 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcAccessFilterService.java @@ -0,0 +1,48 @@ +package backend.academy.scrapper.service.jdbc; + +import backend.academy.scrapper.dao.accessfilter.AccessFilterDao; +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.AccessFilterAlreadyExistException; +import backend.academy.scrapper.exception.filter.AccessFilterNotExistException; +import backend.academy.scrapper.service.AccessFilterService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JdbcAccessFilterService implements AccessFilterService { + + private final AccessFilterDao accessFilterDao; + + @Override + public FilterResponse createFilter(Long id, FilterRequest filterRequest) { + log.info("JdbcAccessFilterService Create filter"); + // Проверяем существование фильтра + if (accessFilterDao.filterExists(filterRequest.filter())) { + log.info("Такой фильтр уже существует: {}", filterRequest.filter()); + throw new AccessFilterAlreadyExistException("Такая ссылка уже существует"); + } + FilterResponse createdFilter = accessFilterDao.createFilter(id, filterRequest); + log.info("Фильтр создан"); + + return createdFilter; + } + + @Override + public FilterListResponse getAllFilter(Long tgChatId) { + log.info("JdbcAccessFilterService getAllFilter"); + return accessFilterDao.getAllFilter(tgChatId); + } + + @Override + public FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest) { + FilterResponse deletedFilter = accessFilterDao.deleteFilter(tgChatId, filterRequest); + if (deletedFilter == null) { + throw new AccessFilterNotExistException("Такого фильтра не существует!"); + } + + return deletedFilter; + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcChatService.java b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcChatService.java new file mode 100644 index 0000000..8d7ca35 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcChatService.java @@ -0,0 +1,46 @@ +package backend.academy.scrapper.service.jdbc; + +import backend.academy.scrapper.dao.chat.TgChatDao; +import backend.academy.scrapper.entity.TgChat; +import backend.academy.scrapper.exception.chat.ChatAlreadyExistsException; +import backend.academy.scrapper.exception.chat.ChatNotExistException; +import backend.academy.scrapper.service.ChatService; +import backend.academy.scrapper.util.Utils; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JdbcChatService implements ChatService { + + private final TgChatDao tgChatDao; + + @Override + public void registerChat(Long id) { + checkIsCorrect(id); + if (tgChatDao.isExistChat(id)) { + throw new ChatAlreadyExistsException("Чат уже существует с таким chatId = " + id); + } + tgChatDao.save(id); + log.info("ChatService: Пользователь зарегистрирован chatId = {}", Utils.sanitize(id)); + } + + @Override + public void deleteChat(Long id) { + checkIsCorrect(id); + + if (!tgChatDao.isExistChat(id)) { + throw new ChatNotExistException("Чат не существует с таким chatId = " + id); + } + + tgChatDao.remove(id); + + log.info("ChatService: Пользователь удален chatId = {}", Utils.sanitize(id)); + } + + @Override + public Optional findChatById(Long id) { + return Optional.empty(); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcLinkService.java b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcLinkService.java new file mode 100644 index 0000000..b1d960b --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcLinkService.java @@ -0,0 +1,105 @@ +package backend.academy.scrapper.service.jdbc; + +import backend.academy.scrapper.dao.TgChatLinkDao; +import backend.academy.scrapper.dao.chat.TgChatDao; +import backend.academy.scrapper.dao.link.LinkDao; +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.LinkService; +import backend.academy.scrapper.util.Utils; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JdbcLinkService implements LinkService { + + private final TgChatDao tgChatDao; + private final LinkDao linkDao; + private final TgChatLinkDao tgChatLinkDao; + + private final LinkMapper mapper; + + @Override + public ListLinksResponse findAllLinksByChatId(Long tgChatId) { + + List linkIdsList = tgChatLinkDao.getLinkIdsByChatId(tgChatId); + + List linkList = linkDao.getListLinksByListLinkId(linkIdsList); + + log.info("LinkService: getAllLinks, chatId = {}", Utils.sanitize(tgChatId)); + + return new ListLinksResponse(mapper.linkListToLinkResponseList(linkList), linkList.size()); + } + + // todo + @Override + public List findAllLinksByChatIdWithFilter(int offset, int batchSize) { + log.info("findAllLinksByChatIdWithFilter, offset = {}, batchSize = {}", offset, batchSize); + return linkDao.findAllLinksByChatIdWithFilter(offset, batchSize); + } + + @Override + public LinkResponse addLink(Long tgChatId, AddLinkRequest request) { + // Все chatId ссылок пользователей + List linkIdsList = tgChatLinkDao.getLinkIdsByChatId(tgChatId); + List linkList = linkDao.getListLinksByListLinkId(linkIdsList); + + if (findLinkByUrl(linkList, request.link().toString()).isPresent()) { + log.warn("Ссылка {} уже существует для чата {}", request.link(), tgChatId); + throw new LinkAlreadyExistException("Такая ссылка уже существует для этого чата"); + } + + Long idLink = linkDao.addLink(request); + tgChatLinkDao.addRecord(tgChatId, idLink); + LinkResponse linkResponse = new LinkResponse(idLink, request.link(), request.tags(), request.filters()); + + log.info("Завершено добавление ссылки для чата с ID: {}", tgChatId); + return linkResponse; + } + + @Override + public LinkResponse deleteLink(Long tgChatId, URI uri) { + if (!tgChatDao.isExistChat(tgChatId)) { + log.error("Чат с ID {} не существует.", tgChatId); + throw new ChatNotExistException("Чат с ID " + tgChatId + " не найден."); + } + // Все chatId ссылок пользователей + List linkIdsList = tgChatLinkDao.getLinkIdsByChatId(tgChatId); + List linkList = linkDao.getListLinksByListLinkId(linkIdsList); + + // Поиск ссылки по URL + Link link = findLinkByUrl(linkList, uri.toString()).orElseThrow(() -> { + log.warn("Ссылка {} не существует для чата {}", uri, tgChatId); + return new LinkNotFoundException("Такая ссылка уже существует для этого чата"); + }); + + // Удаление ссылки + linkDao.remove(link.id()); + + return mapper.linkToLinkResponse(link); + } + + @Override + public Optional findById(Long id) { + return linkDao.findLinkByLinkId(id); + } + + @Override + public void update(Link link) { + linkDao.update(link); + } + + private Optional findLinkByUrl(List list, String url) { + return list.stream().filter(link -> link.url().equals(url)).findFirst(); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcTagService.java b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcTagService.java new file mode 100644 index 0000000..dab34be --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/jdbc/JdbcTagService.java @@ -0,0 +1,99 @@ +package backend.academy.scrapper.service.jdbc; + +import backend.academy.scrapper.dao.TgChatLinkDao; +import backend.academy.scrapper.dao.filter.FilterDao; +import backend.academy.scrapper.dao.link.LinkDao; +import backend.academy.scrapper.dao.tag.TagDao; +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.Link; +import backend.academy.scrapper.entity.Tag; +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.TagService; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JdbcTagService implements TagService { + + private final FilterDao filterDao; + private final TagDao tagDao; + private final LinkDao linkDao; + private final TgChatLinkDao tgChatLinkDao; + private final LinkMapper linkMapper; + + @Override + public ListLinksResponse getListLinkByTag(Long tgChatId, String tag) { + List linkIdsList = tgChatLinkDao.getLinkIdsByChatId(tgChatId); + + List linkList = linkDao.getListLinksByListLinkId(linkIdsList); + + List linkResponseList = new ArrayList<>(); + + for (Link item : linkList) { + List tagList = tagDao.findListTagByLinkId(item.id()); + for (Tag itemTag : tagList) { + if (itemTag.tag().equals(tag)) { + item.filters(filterDao.findListFilterByLinkId(item.id())); + item.tags(tagList); + linkResponseList.add(linkMapper.linkToLinkResponse(item)); + } + } + } + + return new ListLinksResponse(linkResponseList, linkList.size()); + } + + @Override + public TagListResponse getAllListLinks(Long tgChatId) { + List linkList = linkDao.getListLinksByListLinkId(tgChatLinkDao.getLinkIdsByChatId(tgChatId)); + Set tagsSet = new HashSet<>(); + for (Link link : linkList) { + List tagList = tagDao.findListTagByLinkId(link.id()); + tagList.forEach(tag -> tagsSet.add(tag.tag())); + } + return new TagListResponse(new ArrayList<>(tagsSet)); + } + + @Override + public LinkResponse removeTagFromLink(Long tgChatId, TagRemoveRequest tagRemoveRequest) { + List linkList = linkDao.getListLinksByListLinkId(tgChatLinkDao.getLinkIdsByChatId(tgChatId)); + + Optional optLink = linkList.stream() + .filter(link -> link.url().equals(tagRemoveRequest.uri().toString())) + .findFirst(); + + if (optLink.isEmpty()) { + log.warn("Ссылка {} не найдена в чате {}", tagRemoveRequest.uri(), tgChatId); + throw new LinkNotFoundException( + "Ссылка " + tagRemoveRequest.uri() + " не найдена в чате с ID " + tgChatId + "."); + } + + Link link = optLink.orElseThrow(() -> new LinkNotFoundException("Ссылка не найдена")); + + List tagsList = tagDao.findListTagByLinkId(link.id()); + + 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); + } + tagDao.removeTag(link.id(), tagRemoveRequest.tag()); + link.tags(tagsList); + link.filters(filterDao.findListFilterByLinkId(link.id())); + + return linkMapper.linkToLinkResponse(link); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmAccessFilterService.java b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmAccessFilterService.java new file mode 100644 index 0000000..ea5cbd3 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmAccessFilterService.java @@ -0,0 +1,86 @@ +package backend.academy.scrapper.service.orm; + +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.entity.AccessFilter; +import backend.academy.scrapper.entity.TgChat; +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.repository.AccessFilterRepository; +import backend.academy.scrapper.repository.TgChatRepository; +import backend.academy.scrapper.service.AccessFilterService; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OrmAccessFilterService implements AccessFilterService { + + private final TgChatRepository tgChatRepository; + private final AccessFilterRepository accessFilterRepository; + private final FilterMapper filterMapper; + + @Override + public FilterResponse createFilter(Long chatId, FilterRequest filterRequest) { + log.info("Мы в OrmAccessFilterService createFilter"); + + Optional tgChatOptional = tgChatRepository.findById(chatId); + + if (accessFilterRepository.existsAccessFilterByFilter(filterRequest.filter())) { + log.info("Такой фильтр уже существует: {}", filterRequest.filter()); + throw new AccessFilterAlreadyExistException("Такая ссылка уже существует"); + } + + TgChat tgChat = tgChatOptional.orElseThrow(() -> new ChatNotExistException("Чата не существует")); + + AccessFilter accessFilter = accessFilterRepository.save(AccessFilter.create(tgChat, filterRequest.filter())); + + return filterMapper.toFilterResponse(accessFilter); + } + + @Override + public FilterListResponse getAllFilter(Long tgChatId) { + Optional tgChatOptional = tgChatRepository.findById(tgChatId); + + TgChat tgChat = tgChatOptional.orElseThrow(() -> new ChatNotExistException("Чата не существует")); + + return new FilterListResponse(filterMapper.toFilterResponseList(tgChat.accessFilters())); + } + + @Override + public FilterResponse deleteFilter(Long tgChatId, FilterRequest filterRequest) { + log.info("Мы в OrmAccessFilterService FilterResponse"); + + Optional tgChatOptional = tgChatRepository.findById(tgChatId); + + TgChat tgChat = tgChatOptional.orElseThrow(() -> new ChatNotExistException("Чата не существует")); + Optional optionalAccessFilter = + deleteAccessFilter(tgChat.accessFilters(), filterRequest.filter()); + if (optionalAccessFilter.isEmpty()) { + throw new AccessFilterNotExistException("Такого фильтра не существует!"); + } + + AccessFilter accessFilter = + optionalAccessFilter.orElseThrow(() -> new AccessFilterNotExistException("Чата не существует")); + + tgChatRepository.save(tgChat); + return new FilterResponse(accessFilter.id(), accessFilter.filter()); + } + + private Optional deleteAccessFilter(List accessFilterList, String filter) { + for (AccessFilter item : accessFilterList) { + if (item.filter().equals(filter)) { + accessFilterList.remove(item); + return Optional.of(item); + } + } + return Optional.empty(); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmChatService.java b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmChatService.java new file mode 100644 index 0000000..d5305de --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmChatService.java @@ -0,0 +1,61 @@ +package backend.academy.scrapper.service.orm; + +import backend.academy.scrapper.entity.TgChat; +import backend.academy.scrapper.exception.chat.ChatAlreadyExistsException; +import backend.academy.scrapper.exception.chat.ChatNotExistException; +import backend.academy.scrapper.repository.TgChatRepository; +import backend.academy.scrapper.service.ChatService; +import backend.academy.scrapper.util.Utils; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Slf4j +@Service +public class OrmChatService implements ChatService { + + private final TgChatRepository tgChatRepository; + + @Override + @Transactional + public void registerChat(Long id) { + checkIsCorrect(id); + + tgChatRepository.findById(id).ifPresent(tgChat -> { + throw new ChatAlreadyExistsException("Чат уже существует с таким chatId = " + id); + }); + + TgChat tgChat = TgChat.builder() + .id(id) + .createdAt(OffsetDateTime.now(ZoneId.systemDefault())) + .build(); + tgChatRepository.save(tgChat); + + log.info("ChatService: Пользователь зарегистрирован chatId = {}", Utils.sanitize(id)); + } + + @Override + @Transactional + public void deleteChat(Long id) { + checkIsCorrect(id); + + tgChatRepository.findById(id).ifPresent(tgChat -> { + throw new ChatNotExistException("Чата не существует с chatId = " + id); + }); + + tgChatRepository.deleteById(id); + + log.info("ChatService: Пользователь удален chatId = {}", Utils.sanitize(id)); + } + + @Override + @Transactional(readOnly = true) + public Optional findChatById(Long id) { + return tgChatRepository.findById(id); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmLinkService.java b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmLinkService.java new file mode 100644 index 0000000..219d1dd --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmLinkService.java @@ -0,0 +1,164 @@ +package backend.academy.scrapper.service.orm; + +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.AccessFilter; +import backend.academy.scrapper.entity.Filter; +import backend.academy.scrapper.entity.Link; +import backend.academy.scrapper.entity.Tag; +import backend.academy.scrapper.entity.TgChat; +import backend.academy.scrapper.entity.TgChatLink; +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.repository.LinkRepository; +import backend.academy.scrapper.repository.TgChatLinkRepository; +import backend.academy.scrapper.service.ChatService; +import backend.academy.scrapper.service.LinkService; +import backend.academy.scrapper.util.Utils; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Slf4j +@Service +public class OrmLinkService implements LinkService { + + /** Проверка на chatId пользователя не проводится, так как считаем что данные приходят консистентные */ + private final LinkRepository linkRepository; + + private final TgChatLinkRepository tgChatLinkRepository; + private final LinkMapper mapper; + private final ChatService chatService; + + @Transactional(readOnly = true) + @Override + public ListLinksResponse findAllLinksByChatId(Long tgChatId) { + log.info("LinkService: getAllLinks, chatId = {}", Utils.sanitize(tgChatId)); + List linkList = tgChatLinkRepository.findLinksByChatId(tgChatId); + return new ListLinksResponse(mapper.linkListToLinkResponseList(linkList), linkList.size()); + } + + @Transactional + @Override + public LinkResponse addLink(Long tgChatId, AddLinkRequest request) { + + TgChat existingTgChat = chatService + .findChatById(tgChatId) + .orElseThrow(() -> new ChatNotExistException("Чат с ID " + tgChatId + " не найден.")); + + if (tgChatLinkRepository + .findByChatIdAndLinkUrl(tgChatId, request.link().toString()) + .isPresent()) { + throw new LinkAlreadyExistException("Такая ссылка уже существует для этого чата"); + } + + Link newLink = new Link(); + newLink.url(request.link().toString()); + + List tags = request.tags().stream() + .map(tagName -> Tag.create(tagName, newLink)) + .collect(Collectors.toList()); + newLink.tags(tags); + + List filters = request.filters().stream() + .map(filterValue -> Filter.create(filterValue, newLink)) + .collect(Collectors.toList()); + newLink.filters(filters); + + Link savedLink = linkRepository.save(newLink); + + TgChatLink tgChatLink = new TgChatLink(); + tgChatLink.setChat(existingTgChat); + tgChatLink.link(savedLink); + tgChatLinkRepository.save(tgChatLink); + + existingTgChat.tgChatLinks().add(tgChatLink); + + return mapper.linkToLinkResponse(savedLink); + } + + @Transactional + @Override + public LinkResponse deleteLink(Long tgChatId, URI uri) { + // Проверка существования связи между чатом и ссылкой + Optional existingChatLink = tgChatLinkRepository.findByChatIdAndLinkUrl(tgChatId, uri.toString()); + if (existingChatLink.isEmpty()) { + log.warn("Ссылка {} не найдена в чате {}", uri, tgChatId); + throw new LinkNotFoundException("Ссылка " + uri + " не найдена в чате с ID " + tgChatId + "."); + } + + TgChatLink tgChatLinkToDelete = + existingChatLink.orElseThrow(() -> new LinkNotFoundException("Ссылка не найдена")); + Link linkResponse = tgChatLinkToDelete.link(); + tgChatLinkRepository.delete(tgChatLinkToDelete); + log.info("Удалена связь между чатом {} и ссылкой {}", tgChatId, uri); + + // Проверка, остались ли другие связи с этой ссылкой + if (tgChatLinkRepository.countByLinkId(linkResponse.id()) == 0) { + // Если нет других связей, удаляем и саму ссылку + linkRepository.delete(linkResponse); + log.info("Ссылка {} удалена, так как больше не связана ни с одним чатом.", linkResponse.url()); + } else { + log.info("Ссылка {} не удалена, так как связана с другими чатами.", linkResponse.url()); + } + + // Возвращаем ответ + return mapper.linkToLinkResponse(linkResponse); + } + + // Для scheduler + @Transactional(readOnly = true) + @Override + public Optional findById(Long id) { + return linkRepository.findById(id); + } + + @Transactional(readOnly = true) + @Override + public List findAllLinksByChatIdWithFilter(int offset, int limit) { + Pageable pageable = PageRequest.of(offset, limit); + + List list = linkRepository.findAll(pageable).getContent(); + + List listWithFilter = new ArrayList<>(); + + for (Link item : list) { + List tgChatLinkList = item.tgChatLinks(); + for (TgChatLink itemTgChat : tgChatLinkList) { + if (!isCompareFilters(item.filters(), itemTgChat.tgChat().accessFilters())) { + listWithFilter.add(item); + } + } + } + return listWithFilter; + } + + private boolean isCompareFilters(List filtersList, List accessFilterList) { + for (AccessFilter accessFilter : accessFilterList) { + for (Filter filter : filtersList) { + if (accessFilter.filter().equals(filter.filter())) { + return true; + } + } + } + return false; + } + + @Transactional + @Override + public void update(Link link) { + linkRepository.save(link); + } +} diff --git a/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmTagService.java b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmTagService.java new file mode 100644 index 0000000..17b6fd6 --- /dev/null +++ b/scrapper/src/main/java/backend/academy/scrapper/service/orm/OrmTagService.java @@ -0,0 +1,93 @@ +package backend.academy.scrapper.service.orm; + +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.Link; +import backend.academy.scrapper.entity.Tag; +import backend.academy.scrapper.entity.TgChatLink; +import backend.academy.scrapper.exception.link.LinkNotFoundException; +import backend.academy.scrapper.exception.tag.TagNotExistException; +import backend.academy.scrapper.mapper.LinkMapper; +import backend.academy.scrapper.repository.TgChatLinkRepository; +import backend.academy.scrapper.service.LinkService; +import backend.academy.scrapper.service.TagService; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class OrmTagService implements TagService { + + private final LinkService linkService; + private final TgChatLinkRepository tgChatLinkRepository; + private final LinkMapper linkMapper; + + @Transactional + @Override + public ListLinksResponse getListLinkByTag(Long tgChatId, String tag) { + + List linkResponseList = + linkService.findAllLinksByChatId(tgChatId).links(); + + List ans = new ArrayList<>(); + + for (LinkResponse linkResponse : linkResponseList) { + if (linkResponse.tags().contains(tag)) { + ans.add(linkResponse); + } + } + return new ListLinksResponse(ans, linkResponseList.size()); + } + + @Transactional + @Override + public TagListResponse getAllListLinks(Long tgChatId) { + List linkResponseList = + linkService.findAllLinksByChatId(tgChatId).links(); + Set tags = new HashSet<>(); + + for (LinkResponse linkResponse : linkResponseList) { + tags.addAll(linkResponse.tags()); + } + log.info("LinkService: getAllListLinks, tags = {}", tags); + return new TagListResponse(new ArrayList<>(tags)); + } + + @Transactional + @Override + public LinkResponse removeTagFromLink(Long tgChatId, TagRemoveRequest tagRemoveRequest) { + log.info("Удаление тега из ссылки: tgChatId={}, tagRemoveRequest={}", tgChatId, tagRemoveRequest.tag()); + Optional tgChatLinkOptional = tgChatLinkRepository.findByChatIdAndLinkUrl( + tgChatId, tagRemoveRequest.uri().toString()); + if (tgChatLinkOptional.isEmpty()) { + log.error("Ссылка {} не найдена в чате с ID {}", tagRemoveRequest.tag(), tgChatId); + throw new LinkNotFoundException("Ссылка " + tagRemoveRequest.tag() + " не найдена в чате с ID " + tgChatId); + } + + TgChatLink tgChatLink = tgChatLinkOptional.orElseThrow(() -> new LinkNotFoundException("Ссылка не найдена")); + Link link = tgChatLink.link(); + + List 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()); + } +}